From 3ae87a69bb146365c017de66d0c94f5805c2cd05 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/homebrew.yml | 2 +- .github/workflows/test.yml | 169 ++++++++++------------------- .gitignore | 2 +- .pre-commit-config.yaml | 25 +++++ Cargo.lock | 130 ++++++++++++++++++++--- Cross.toml | 41 ++++--- Dockerfile | 4 +- gping.1 | 125 +++++++--------------- gping/Cargo.toml | 1 + gping/build.rs | 4 +- gping/src/lib.rs | 1 - gping/src/main.rs | 32 ++++-- pinger/Cargo.toml | 14 ++- pinger/README.md | 15 +-- 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/tests/android.txt | 2 +- pinger/src/windows.rs | 71 +++++++------ readme.md | 4 +- 26 files changed, 787 insertions(+), 568 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 gping/src/lib.rs 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/homebrew.yml b/.github/workflows/homebrew.yml index 992425e0d..c223dec1a 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -19,7 +19,7 @@ jobs: formula-name: gping commit-message: | gping ${{ steps.extract-version.outputs.VERSION }} - + Created by https://github.com/mislav/bump-homebrew-formula-action env: COMMITTER_TOKEN: ${{ secrets.COMMITTER_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30101b40a..3488efae8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,151 +10,84 @@ on: name: CI jobs: - build_and_test: - name: Rust project + cross_builds: + name: ${{ matrix.target }} runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} strategy: + fail-fast: false matrix: - os: [ ubuntu-latest, macos-latest, windows-latest ] include: - - os: ubuntu-latest - bin: gping - name: gping-Linux-x86_64.tar.gz - container: quay.io/pypa/manylinux_2_28_x86_64 - - os: macOS-latest - bin: gping - name: gping-Darwin-x86_64.tar.gz - container: null - - os: windows-latest - bin: gping.exe - name: gping-Windows-x86_64.zip - container: null - env: - RUST_BACKTRACE: "1" - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: standard-build-${{ matrix.os }}- - - - name: Run tests - run: cargo test - - - name: Run - run: cargo run -- --help - - - name: Build release - if: startsWith(github.ref, 'refs/tags/') - run: cargo build --release - - name: Package - if: startsWith(github.ref, 'refs/tags/') - shell: bash - run: | - strip target/release/${{ matrix.bin }} - cd target/release - if [[ "${{ matrix.os }}" == "windows-latest" ]] - then - 7z a ../../${{ matrix.name }} ${{ matrix.bin }} - else - tar czvf ../../${{ matrix.name }} ${{ matrix.bin }} - fi - cd - - - name: Archive binaries - uses: actions/upload-artifact@v4 - if: startsWith(github.ref, 'refs/tags/') - with: - 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 - runs-on: ubuntu-latest - strategy: - matrix: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + archive: zip + os: [ 'ubuntu-24.04' ] target: - armv7-linux-androideabi - armv7-unknown-linux-gnueabihf - armv7-unknown-linux-musleabihf + - x86_64-unknown-linux-gnu + - x86_64-unknown-linux-musl - aarch64-unknown-linux-gnu - aarch64-unknown-linux-musl - - x86_64-unknown-linux-musl steps: - uses: actions/checkout@v4 + - name: Install Rust - uses: dtolnay/rust-toolchain@stable + id: rust + uses: actions-rust-lang/setup-rust-toolchain@v1 with: + cache: 'false' + cache-on-failure: false target: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - prefix-key: cross-build-${{ matrix.target }}- - - name: Check - uses: houseabsolute/actions-rust-cross@v0 + - name: Setup Rust Caching + uses: Swatinem/rust-cache@v2 with: - command: check - target: ${{ matrix.target }} + cache-on-failure: false + prefix-key: ${{ matrix.target }} + key: ${{ steps.rust.outputs.cachekey }} - name: Test uses: houseabsolute/actions-rust-cross@v0 with: command: test target: ${{ matrix.target }} + args: --locked - - uses: houseabsolute/actions-rust-cross@v0 - if: startsWith(github.ref, 'refs/tags/') + - name: Build release + uses: houseabsolute/actions-rust-cross@v0 + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' with: command: build target: ${{ matrix.target }} - args: --release - - name: Package - if: startsWith(github.ref, 'refs/tags/') - shell: bash - run: | - cd target/${{ matrix.target }}/release/ - tar czvf ../../../gping-${{ matrix.target }}.tar.gz gping - cd - - - name: Archive production artifacts - uses: actions/upload-artifact@v4 - if: startsWith(github.ref, 'refs/tags/') + args: --release --locked + + - name: Publish artifacts and release + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + uses: houseabsolute/actions-rust-release@v0 with: - name: build-${{ matrix.target }} - path: | - gping*.tar.gz - gping*.zip + executable-name: gping + target: ${{ matrix.target }} + extra-files: gping.1 create_release: name: Release runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' needs: - cross_builds - - build_and_test steps: - name: Checkout sources uses: actions/checkout@v4 - uses: actions/download-artifact@v4 + with: + merge-multiple: true - name: Publish + if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: draft: false @@ -167,26 +100,30 @@ jobs: checks: name: Checks - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout sources uses: actions/checkout@v4 - - name: Install stable toolchain - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-python@v5 with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + python-version: '3.11' + + - name: Install stable toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 with: - prefix-key: checks-build- + cache-on-failure: false + components: rustfmt,clippy - - name: Run cargo fmt - if: success() || failure() - run: cargo fmt --all -- --check + - name: Rustfmt Check + uses: actions-rust-lang/rustfmt@v1 - name: Run cargo check if: success() || failure() run: cargo check - if: success() || failure() - run: cargo clippy --all-targets --all-features -- -D warnings + run: cargo clippy --all-targets --all-features --locked -- -D warnings + + - if: success() || failure() + uses: pre-commit/action@v3.0.1 diff --git a/.gitignore b/.gitignore index 6b39d31a9..c403c348d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ /target -.idea/ \ No newline at end of file +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..2fb2f1212 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + exclude: 'ping.1' + - repo: local + hooks: + - id: rustfmt + name: rustfmt + entry: cargo fmt -- --check + pass_filenames: false + language: system + - id: clippy + name: clippy + entry: cargo clippy --all-targets --all-features -- -D warnings + pass_filenames: false + language: system + - id: mangen + name: mangen + entry: env GENERATE_MANPAGE="gping.1" cargo run + pass_filenames: false + language: system diff --git a/Cargo.lock b/Cargo.lock index 63ab0efe3..cff7bcd1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,7 +186,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -195,6 +195,16 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +[[package]] +name = "clap_mangen" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbae9cbfdc5d4fa8711c09bd7b83f644cb48281ac35bf97af3e47b0675864bdf" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "colorchoice" version = "1.0.3" @@ -287,7 +297,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.90", ] [[package]] @@ -298,7 +308,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -380,6 +390,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "clap_mangen", "const_format", "crossterm", "dns-lookup", @@ -435,6 +446,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 +473,7 @@ dependencies = [ "pretty_assertions", "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -512,7 +533,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.90", ] [[package]] @@ -576,6 +597,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 +705,8 @@ name = "pinger" version = "1.3.0" dependencies = [ "anyhow", - "dns-lookup", "lazy-regex", + "ntest", "os_info", "rand", "thiserror", @@ -684,6 +738,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" @@ -791,6 +854,12 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "roff" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" + [[package]] name = "rustix" version = "0.38.42" @@ -839,7 +908,7 @@ checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -936,7 +1005,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 +1047,7 @@ checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] [[package]] @@ -1003,6 +1083,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 +1174,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-shared", ] @@ -1099,7 +1196,7 @@ checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1299,6 +1396,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 +1440,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.90", ] diff --git a/Cross.toml b/Cross.toml index ec0b78464..dc0f17e8d 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"] diff --git a/Dockerfile b/Dockerfile index 52dd79182..782ab9e77 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY gping/ gping/ COPY pinger/ pinger/ COPY Cargo.* ./ -RUN cargo install --locked --path ./gping +RUN cargo install --locked --path ./gping FROM debian:bookworm-slim @@ -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.1 b/gping.1 index 1f2b77b7a..9329e0a4c 100644 --- a/gping.1 +++ b/gping.1 @@ -1,103 +1,60 @@ -.\" Automatically generated by Pandoc 2.17.1.1 -.\" -.\" Define V font for inline verbatim, using C font in formats -.\" that render this, and otherwise B font. -.ie "\f[CB]x\f[]"x" \{\ -. ftr V B -. ftr VI BI -. ftr VB B -. ftr VBI BI -.\} -.el \{\ -. ftr V CR -. ftr VI CI -. ftr VB CB -. ftr VBI CBI -.\} -.TH "gping" "utils" "\[lq]January 11 2023\[rq]" "" "User Commands" -.hy +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH gping 1 "gping " .SH NAME -.PP -gping - ping(1), but with a graph +gping \- Ping, but with a graph. .SH SYNOPSIS -.PP -\f[B]gping\f[R] \f[B]OPTIONS\f[R] \f[B][HOSTS]\f[R] -.PP -\f[B]gping\f[R] ccc.de -.PP -\f[B]gping\f[R] \f[B]-s\f[R] \f[B]-b 20\f[R] ccc.de +\fBgping\fR [\fB\-\-cmd\fR] [\fB\-n\fR|\fB\-\-watch\-interval\fR] [\fB\-b\fR|\fB\-\-buffer\fR] [\fB\-4 \fR] [\fB\-6 \fR] [\fB\-i\fR|\fB\-\-interface\fR] [\fB\-s\fR|\fB\-\-simple\-graphics\fR] [\fB\-\-vertical\-margin\fR] [\fB\-\-horizontal\-margin\fR] [\fB\-c\fR|\fB\-\-color\fR] [\fB\-\-clear\fR] [\fB\-h\fR|\fB\-\-help\fR] [\fIHOSTS_OR_COMMANDS\fR] .SH DESCRIPTION -.PP -This manual page documents briefly the \f[B]gping\f[R] command. -.PP -This manual page was written for the Debian distribution because the -original program does not have a manual page. -.PP -\f[B]gping\f[R] is essentially ping(1), but with a TUI graph. +Ping, but with a graph. .SH OPTIONS .TP -\f[B]--cmd\f[R] -Graph the execution time for a list of commands rather than pinging -hosts +\fB\-\-cmd\fR +Graph the execution time for a list of commands rather than pinging hosts .TP -\f[B]-h\f[R], \f[B]--help\f[R] -Show summary of options. +\fB\-n\fR, \fB\-\-watch\-interval\fR=\fIWATCH_INTERVAL\fR +Watch interval seconds (provide partial seconds like \*(Aq0.5\*(Aq). Default for ping is 0.2, default for cmd is 0.5. .TP -\f[B]-V\f[R], \f[B]--version\f[R] -Print version information -.TP -\f[B]-n\f[R], \f[B]--watch-interval\f[R] -Watch interval seconds (provide partial seconds like `0.5'). -Default for ping is 0.2, -default for cmd is 0.5. -.TP -\f[B]-b\f[R], \f[B]--buffer\f[R] +\fB\-b\fR, \fB\-\-buffer\fR=\fIBUFFER\fR [default: 30] Determines the number of seconds to display in the graph. -[default: 30] .TP -\f[B]-4\f[R] +\fB\-4\fR Resolve ping targets to IPv4 address .TP -\f[B]-6\f[R] +\fB\-6\fR Resolve ping targets to IPv6 address .TP -\f[B]-i\f[R], \f[B]--interface\f[R] -Interface to use when pinging. -?? +\fB\-i\fR, \fB\-\-interface\fR=\fIINTERFACE\fR +Interface to use when pinging .TP -\f[B]-s\f[R], \f[B]--simple-graphics\f[R] +\fB\-s\fR, \fB\-\-simple\-graphics\fR Uses dot characters instead of braille .TP -\f[B]--vertical-margin\f[R] -Vertical margin around the graph (top and bottom) [default: 1] +\fB\-\-vertical\-margin\fR=\fIVERTICAL_MARGIN\fR [default: 1] +Vertical margin around the graph (top and bottom) .TP -\f[B]--horizontal-margin\f[R] -Horizontal margin around the graph (left and right) [default: 0] +\fB\-\-horizontal\-margin\fR=\fIHORIZONTAL_MARGIN\fR [default: 0] +Horizontal margin around the graph (left and right) .TP -\f[B]-c\f[R], \f[B]\[en]color\f[R] +\fB\-c\fR, \fB\-\-color\fR=\fIcolor\fR Assign color to a graph entry. -This option can be defined more than once as a comma separated string, -and the order which the colors are provided will be matched against the -hosts or commands passed to gping. -Hexadecimal RGB color codes are accepted in the form of `#RRGGBB' or the -following color names: `black', `red', `green', `yellow', `blue', -`magenta',`cyan', `gray', `dark-gray', `light-red', `light-green', -`light-yellow', -`light-blue', `light-magenta', `light-cyan', and `white' -.SH AUTHOR -.TP -Matthias Geiger -Wrote this manpage for the Debian system. -.SH COPYRIGHT -.PP -Copyright \[co] 2023 Matthias Geiger -.PP -This manual page was written for the Debian system (and may be used by -others). -.PP -Permission is granted to copy, distribute and/or modify this document -under the terms of the GNU General Public License, Version 2 or (at your -option) any later version published by the Free Software Foundation. -.PP -On Debian systems, the complete text of the GNU General Public License -can be found in /usr/share/common-licenses/GPL. + +This option can be defined more than once as a comma separated string, and the +order which the colors are provided will be matched against the hosts or +commands passed to gping. + +Hexadecimal RGB color codes are accepted in the form of \*(Aq#RRGGBB\*(Aq or the +following color names: \*(Aqblack\*(Aq, \*(Aqred\*(Aq, \*(Aqgreen\*(Aq, \*(Aqyellow\*(Aq, \*(Aqblue\*(Aq, \*(Aqmagenta\*(Aq, +\*(Aqcyan\*(Aq, \*(Aqgray\*(Aq, \*(Aqdark\-gray\*(Aq, \*(Aqlight\-red\*(Aq, \*(Aqlight\-green\*(Aq, \*(Aqlight\-yellow\*(Aq, +\*(Aqlight\-blue\*(Aq, \*(Aqlight\-magenta\*(Aq, \*(Aqlight\-cyan\*(Aq, and \*(Aqwhite\*(Aq +.TP +\fB\-\-clear\fR +Clear the graph from the terminal after closing the program +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +[\fIHOSTS_OR_COMMANDS\fR] +Hosts or IPs to ping, or commands to run if \-\-cmd is provided. Can use cloud shorthands like aws:eu\-west\-1. +.SH AUTHORS +Tom Forbes diff --git a/gping/Cargo.toml b/gping/Cargo.toml index 5a3c9962b..5c64bf7d5 100644 --- a/gping/Cargo.toml +++ b/gping/Cargo.toml @@ -20,6 +20,7 @@ itertools = "0.13.0" shadow-rs = { version = "0.37.0", default-features = false } const_format = "0.2.34" clap = { version = "4.5.23", features = ["derive"] } +clap_mangen = "0.2.24" [build-dependencies] shadow-rs = { version = "0.37.0", default-features = false } 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/lib.rs b/gping/src/lib.rs deleted file mode 100644 index 19323ea3e..000000000 --- a/gping/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod region_map; diff --git a/gping/src/main.rs b/gping/src/main.rs index 69c9df2d7..72441c099 100644 --- a/gping/src/main.rs +++ b/gping/src/main.rs @@ -1,7 +1,7 @@ use crate::plot_data::PlotData; use anyhow::{anyhow, Result}; use chrono::prelude::*; -use clap::Parser; +use clap::{CommandFactory, Parser}; use crossterm::event::KeyModifiers; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{ @@ -11,12 +11,13 @@ 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; use std::net::IpAddr; use std::ops::Add; +use std::path::Path; use std::process::{Command, ExitStatus, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; @@ -54,7 +55,7 @@ build_env: {},{}"#, ); #[derive(Parser, Debug)] -#[command(author, name = "gping", about = "Ping, but with a graph.", version = VERSION_INFO)] +#[command(author, version=build::PKG_VERSION, name = "gping", about = "Ping, but with a graph.", long_version = VERSION_INFO)] struct Args { #[arg( long, @@ -107,15 +108,15 @@ struct Args { long = "color", use_value_delimiter = true, value_delimiter = ',', - help = r#"Assign color to a graph entry. + help = r#"Assign color to a graph entry. -This option can be defined more than once as a comma separated string, and the -order which the colors are provided will be matched against the hosts or +This option can be defined more than once as a comma separated string, and the +order which the colors are provided will be matched against the hosts or commands passed to gping. -Hexadecimal RGB color codes are accepted in the form of '#RRGGBB' or the +Hexadecimal RGB color codes are accepted in the form of '#RRGGBB' or the following color names: 'black', 'red', 'green', 'yellow', 'blue', 'magenta', -'cyan', 'gray', 'dark-gray', 'light-red', 'light-green', 'light-yellow', +'cyan', 'gray', 'dark-gray', 'light-red', 'light-green', 'light-yellow', 'light-blue', 'light-magenta', 'light-cyan', and 'white'"# )] color_codes_or_names: Vec, @@ -310,7 +311,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() { @@ -350,7 +352,19 @@ fn get_host_ipaddr(host: &str, force_ipv4: bool, force_ipv6: bool) -> Result anyhow::Result<()> { + let man = clap_mangen::Man::new(Args::command().version(None).long_version(None)); + let mut buffer: Vec = Default::default(); + man.render(&mut buffer)?; + + std::fs::write(path, buffer)?; + Ok(()) +} + fn main() -> Result<()> { + if let Some(path) = std::env::var_os("GENERATE_MANPAGE") { + return generate_man_page(Path::new(&path)); + }; let args: Args = Args::parse(); if args.hosts_or_commands.is_empty() { diff --git a/pinger/Cargo.toml b/pinger/Cargo.toml index 6383fd203..06ad19fdd 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"] diff --git a/pinger/README.md b/pinger/README.md index ae1c96d3d..469ceecc9 100644 --- a/pinger/README.md +++ b/pinger/README.md @@ -2,22 +2,24 @@ > A small cross-platform library to execute the ping command and parse the output. -This crate is primarily built for use with `gping`, but it can also be used as a +This crate is primarily built for use with `gping`, but it can also be used as a standalone library. -This allows you to reliably ping hosts without having to worry about process permissions, +This allows you to reliably ping hosts without having to worry about process permissions, in a cross-platform manner on Windows, Linux and macOS. ## Usage -A full example of using the library can be found in the `examples/` directory, but the +A full example of using the library can be found in the `examples/` directory, but the 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, _) => { @@ -26,10 +28,9 @@ fn ping_google() { _ => {} // Handle errors, log ping timeouts, etc. } } -} +} ``` ## Adding pinger to your project. `cargo add pinger` - 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/tests/android.txt b/pinger/src/tests/android.txt index a319c8b2a..8150afe9c 100644 --- a/pinger/src/tests/android.txt +++ b/pinger/src/tests/android.txt @@ -22,4 +22,4 @@ None None None None -None \ No newline at end of file +None 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) - } -} diff --git a/readme.md b/readme.md index 334669fbd..b4b40491b 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ Table of Contents * [MacPorts](https://ports.macports.org/port/gping/): `sudo port install gping` * Linux (Homebrew): `brew install gping` * CentOS (and other distributions with an old glibc): Download the MUSL build from the latest release -* Windows/ARM: +* Windows/ARM: * Scoop: `scoop install gping` * Chocolatey: `choco install gping` * Download the latest release from [the github releases page](https://github.com/orf/gping/releases) @@ -71,7 +71,7 @@ flox install gping # Usage :saxophone: -Just run `gping [host]`. `host` can be a command like `curl google.com` if the `--cmd` flag is used. You can also use +Just run `gping [host]`. `host` can be a command like `curl google.com` if the `--cmd` flag is used. You can also use shorthands like `aws:eu-west-1` or `aws:ca-central-1` to ping specific cloud regions. Only `aws` is currently supported. ```bash