diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f48e57 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,297 @@ +# This file was autogenerated by dist: https://github.com/astral-sh/cargo-dist +# +# Copyright 2022-2024, axodotdev +# Copyright 2025 Astral Software Inc. +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/cargo-dist/releases/download/v0.28.7/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v4 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: recursive diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..75b02f6 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,21 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: cargo build --verbose + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 1341067..7910e2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,15 +23,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "0.6.20" @@ -193,17 +184,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "num-traits", - "windows-link 0.2.0", -] - [[package]] name = "clap" version = "4.5.48" @@ -487,15 +467,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "hostname" version = "0.4.1" @@ -619,30 +590,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.0.0" @@ -827,12 +774,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.175" @@ -1003,12 +944,11 @@ dependencies = [ [[package]] name = "nifa" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", "crossterm", - "home", "hostname", "humansize", "ndb-oui", @@ -1022,16 +962,7 @@ dependencies = [ "termtree", "tokio", "tracing", - "tracing-subscriber", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" -dependencies = [ - "windows-sys 0.52.0", + "url", ] [[package]] @@ -1040,15 +971,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "object" version = "0.36.7" @@ -1464,9 +1386,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b369d18893388b345804dc0007963c99b7d665ae71d275812d828c6f089640" +checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" dependencies = [ "bitflags", "core-foundation 0.10.1", @@ -1553,15 +1475,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "1.3.0" @@ -1779,15 +1692,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "time" version = "0.3.44" @@ -1957,34 +1861,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" -dependencies = [ - "chrono", - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "time", - "tracing-core", - "tracing-log", ] [[package]] @@ -2053,12 +1929,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "virtue" version = "0.0.18" @@ -2221,41 +2091,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-core" -version = "0.62.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.0", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "windows-link" version = "0.1.3" @@ -2268,24 +2103,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" -[[package]] -name = "windows-result" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" -dependencies = [ - "windows-link 0.2.0", -] - -[[package]] -name = "windows-strings" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" -dependencies = [ - "windows-link 0.2.0", -] - [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 6c0f0ce..1a325a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nifa" -version = "0.1.0" +version = "0.2.0" edition = "2024" authors = ["shellrow "] description = "Cross-platform CLI tool for network information" @@ -15,7 +15,6 @@ license = "MIT" [dependencies] anyhow = { version = "1" } tracing = { version = "0.1" } -tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } serde_yaml = { version = "0.9" } @@ -24,10 +23,17 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots"] } clap = { version = "4.5", features = ["derive", "cargo"] } termtree = { version = "0.5" } -home = { version = "0.5" } hostname = { version = "0.4" } os_info = { version = "3.12" } ndb-oui = { version = "0.3", features = ["bundled"] } ratatui = "0.25" crossterm = "0.27" humansize = "2.1" +url = "2.5" +#tracing-subscriber = { version = "0.3", features = ["time", "chrono"] } +#home = { version = "0.5" } + +# The profile that 'dist' will build with +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/README.md b/README.md index 9263c1f..6cffaa7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Cross-platform CLI tool for network information - Monitor live traffic statistics in TUI - Export snapshot in JSON or YAML for automation - Fetch your public IPv4/IPv6 -- Display system (OS) information along with default interface +- Display system information along with default interface ## Supported Platforms - **Linux** diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..5a17490 --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,17 @@ +[workspace] +members = ["cargo:."] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.28.7" +# CI backends to support +ci = "github" +# The installers to generate for each app +installers = ["shell", "powershell"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false diff --git a/src/cli.rs b/src/cli.rs index 941c42b..2a88cca 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -19,17 +19,17 @@ pub struct Cli { /// With vendor info (OUI lookup) #[arg(long, default_value_t = false)] pub with_vendor: bool, - + /// Subcommand #[command(subcommand)] pub command: Option, } #[derive(Debug, Clone, Copy, ValueEnum)] -pub enum OutputFormat { - Tree, - Json, - Yaml +pub enum OutputFormat { + Tree, + Json, + Yaml, } #[derive(Debug, Subcommand)] @@ -62,23 +62,22 @@ pub struct ListArgs { pub down: bool, /// Show physical interfaces only #[arg(long, conflicts_with = "virt")] - pub physical: bool, + pub phy: bool, /// Show virtual interfaces only #[arg(long)] pub virt: bool, /// Show interfaces with IPv4 address only #[arg(long)] - pub has_ipv4: bool, + pub ipv4: bool, /// Show interfaces with IPv6 address only #[arg(long)] - pub has_ipv6: bool, + pub ipv6: bool, } /// Show command arguments #[derive(Args, Debug)] pub struct ShowArgs { /// Show details for specified interface - #[arg(short, long)] pub iface: String, } @@ -92,7 +91,7 @@ pub struct MonitorArgs { #[arg(short='s', long, value_enum, default_value_t=SortKey::Total)] pub sort: SortKey, /// Monitor interval in seconds - #[arg(short='d', long, default_value="1")] + #[arg(short = 'd', long, default_value = "1")] pub interval: u64, /// Display unit (bytes or bits) #[arg(long, value_enum, default_value_t=Unit::Bytes)] @@ -111,8 +110,8 @@ pub struct ExportArgs { pub struct PublicArgs { /// IPv4 only #[arg(long)] - pub v4_only: bool, + pub ipv4: bool, /// Timeout seconds - #[arg(long, default_value_t=3)] + #[arg(long, default_value_t = 3)] pub timeout: u64, } diff --git a/src/cmd/export.rs b/src/cmd/export.rs index c56c233..39704b8 100644 --- a/src/cmd/export.rs +++ b/src/cmd/export.rs @@ -17,7 +17,9 @@ pub fn export_snapshot(cli: &Cli, args: &ExportArgs) -> Result<()> { eprintln!("Exported {} bytes to {}", bytes.len(), path.display()); } else { // if no output file, write to stdout - std::io::stdout().write_all(&bytes).context("write stdout")?; + std::io::stdout() + .write_all(&bytes) + .context("write stdout")?; } Ok(()) } diff --git a/src/cmd/list.rs b/src/cmd/list.rs index fe2f666..5dbe1bd 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,13 +1,15 @@ -use netdev::Interface; use crate::cli::Cli; use crate::cli::ListArgs; use crate::collector; use crate::renderer; +use netdev::Interface; /// Default action with no subcommand pub fn show_interfaces(cli: &Cli) { let interfaces: Vec = if cli.default { - collector::iface::get_default_interface().into_iter().collect() + collector::iface::get_default_interface() + .into_iter() + .collect() } else { collector::iface::collect_all_interfaces() }; @@ -32,16 +34,16 @@ pub fn list_interfaces(cli: &Cli, args: &ListArgs) { if args.down { interfaces.retain(|iface| iface.oper_state == netdev::interface::OperState::Down); } - if args.physical { + if args.phy { interfaces.retain(|iface| iface.is_physical()); } if args.virt { interfaces.retain(|iface| !iface.is_physical()); } - if args.has_ipv4 { + if args.ipv4 { interfaces.retain(|iface| !iface.ipv4.is_empty()); } - if args.has_ipv6 { + if args.ipv6 { interfaces.retain(|iface| !iface.ipv6.is_empty()); } diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index f308ee0..9c839a5 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,6 +1,6 @@ -pub mod list; -pub mod show; -pub mod os; pub mod export; +pub mod list; pub mod monitor; +pub mod os; pub mod public; +pub mod show; diff --git a/src/cmd/monitor.rs b/src/cmd/monitor.rs index fea9fef..f978358 100644 --- a/src/cmd/monitor.rs +++ b/src/cmd/monitor.rs @@ -4,46 +4,47 @@ use std::time::{Duration, Instant}; use anyhow::Result; use clap::ValueEnum; +use crossterm::event::KeyEventKind; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers}, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use humansize::{format_size, BINARY}; +use humansize::{BINARY, format_size}; use ratatui::text::Text; use ratatui::widgets::{Paragraph, Wrap}; use ratatui::{ + Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, - style::{Style, Modifier, Color}, + style::{Color, Modifier, Style}, text::Span, - widgets::{Block, Borders, Row, Table, Clear}, - Terminal, + widgets::{Block, Borders, Clear, Row, Table}, }; use termtree::Tree; use crate::cli::Cli; -use crate::collector::iface::collect_all_interfaces; use crate::cli::MonitorArgs; +use crate::collector::iface::collect_all_interfaces; use crate::renderer::tree::{fmt_bps, fmt_flags, tree_label}; #[derive(Clone, Copy, Debug, ValueEnum)] -pub enum SortKey { +pub enum SortKey { Total, TotalRx, TotalTx, - Rx, - Tx + Rx, + Tx, } impl SortKey { fn cycle(self) -> Self { - match self { - SortKey::Total => SortKey::TotalRx, + match self { + SortKey::Total => SortKey::TotalRx, SortKey::TotalRx => SortKey::TotalTx, SortKey::TotalTx => SortKey::Rx, - SortKey::Rx => SortKey::Tx, - SortKey::Tx => SortKey::Total, + SortKey::Rx => SortKey::Tx, + SortKey::Tx => SortKey::Total, } } } @@ -77,6 +78,7 @@ struct Rate { struct RowData { index: u32, name: String, + friendly_name: Option, total: u64, total_tx: u64, total_rx: u64, @@ -104,15 +106,20 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { ifs.retain(|it| &it.name == name); } + let max_name_len = get_max_if_name_len(&ifs); + let mut prev: HashMap = HashMap::new(); for itf in &mut ifs { let _ = itf.update_stats(); if let Some(st) = &itf.stats { - prev.insert(itf.name.clone(), StatPoint { - rx_bytes: st.rx_bytes, - tx_bytes: st.tx_bytes, - ts: Instant::now(), - }); + prev.insert( + itf.name.clone(), + StatPoint { + rx_bytes: st.rx_bytes, + tx_bytes: st.tx_bytes, + ts: Instant::now(), + }, + ); } } let mut rows_cache: Vec = Vec::new(); @@ -134,35 +141,47 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Input processing (wait for the remaining time. If tick comes, exit with false) if event::poll(remain)? { - match event::read()? { - Event::Key(KeyEvent { code: KeyCode::Char('q'), .. }) => return Ok(()), - Event::Key(KeyEvent { code: KeyCode::Char('c'), modifiers, .. }) if modifiers.contains(KeyModifiers::CONTROL) => return Ok(()), - Event::Key(KeyEvent { code: KeyCode::Char('o'), .. }) => sort = sort.cycle(), - Event::Key(KeyEvent { code: KeyCode::Char('r'), .. }) => { - ifs = collect_all_interfaces(); - if let Some(ref name) = target_iface { ifs.retain(|it| &it.name == name); } - prev.clear(); - }, - Event::Key(KeyEvent { code: KeyCode::Up, .. }) | Event::Key(KeyEvent { code: KeyCode::Char('w'), .. }) if !popup_open => { - if selected > 0 { selected -= 1; } - }, - Event::Key(KeyEvent { code: KeyCode::Down, .. }) | Event::Key(KeyEvent { code: KeyCode::Char('s'), .. }) if !popup_open => { - if selected + 1 < rows_cache.len() { selected += 1; } - }, - Event::Key(KeyEvent { code: KeyCode::Up, .. }) | Event::Key(KeyEvent { code: KeyCode::Char('w'), .. }) if popup_open => { - popup_scroll = popup_scroll.saturating_sub(1); - }, - Event::Key(KeyEvent { code: KeyCode::Down, .. }) | Event::Key(KeyEvent { code: KeyCode::Char('s'), .. }) if popup_open => { - popup_scroll = popup_scroll.saturating_add(1); - }, - Event::Key(KeyEvent { code: KeyCode::Enter, .. }) => { - popup_open = true; - popup_scroll = 0; - }, - Event::Key(KeyEvent { code: KeyCode::Esc, .. }) => { - popup_open = false; - }, - _ => {} + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + return Ok(()); + } + KeyCode::Char('o') => sort = sort.cycle(), + KeyCode::Char('r') => { + ifs = collect_all_interfaces(); + if let Some(ref name) = target_iface { + ifs.retain(|it| &it.name == name); + } + prev.clear(); + } + KeyCode::Up | KeyCode::Char('w') if !popup_open => { + if selected > 0 { + selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('s') if !popup_open => { + if selected + 1 < rows_cache.len() { + selected += 1; + } + } + KeyCode::Up | KeyCode::Char('w') if popup_open => { + popup_scroll = popup_scroll.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('s') if popup_open => { + popup_scroll = popup_scroll.saturating_add(1); + } + KeyCode::Enter => { + popup_open = true; + popup_scroll = 0; + } + KeyCode::Esc => { + popup_open = false; + } + _ => {} + } + } } } @@ -188,11 +207,16 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let rate = if let Some(prevp) = prev.get(&key) { let dt = nowp.ts.duration_since(prevp.ts).as_secs_f64().max(0.001); Rate { - rx_per_s: (nowp.rx_bytes.saturating_sub(prevp.rx_bytes) as f64) / dt, - tx_per_s: (nowp.tx_bytes.saturating_sub(prevp.tx_bytes) as f64) / dt, + rx_per_s: (nowp.rx_bytes.saturating_sub(prevp.rx_bytes) as f64) + / dt, + tx_per_s: (nowp.tx_bytes.saturating_sub(prevp.tx_bytes) as f64) + / dt, } } else { - Rate { rx_per_s: 0.0, tx_per_s: 0.0 } + Rate { + rx_per_s: 0.0, + tx_per_s: 0.0, + } }; // Update prev for next time (only on tick) @@ -201,6 +225,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { rows.push(RowData { index: itf.index, name: itf.name.clone(), + friendly_name: itf.friendly_name.clone(), total_rx: st.rx_bytes, total_tx: st.tx_bytes, total: st.rx_bytes + st.tx_bytes, @@ -212,15 +237,17 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Sort and replace cache (only on tick) match sort { - SortKey::Total => rows.sort_by(|a,b| b.total.cmp(&a.total)), - SortKey::TotalRx => rows.sort_by(|a,b| b.total_rx.cmp(&a.total_rx)), - SortKey::TotalTx => rows.sort_by(|a,b| b.total_tx.cmp(&a.total_tx)), - SortKey::Rx => rows.sort_by(|a,b| b.rx.total_cmp(&a.rx)), - SortKey::Tx => rows.sort_by(|a,b| b.tx.total_cmp(&a.tx)), + SortKey::Total => rows.sort_by(|a, b| b.total.cmp(&a.total)), + SortKey::TotalRx => rows.sort_by(|a, b| b.total_rx.cmp(&a.total_rx)), + SortKey::TotalTx => rows.sort_by(|a, b| b.total_tx.cmp(&a.total_tx)), + SortKey::Rx => rows.sort_by(|a, b| b.rx.total_cmp(&a.rx)), + SortKey::Tx => rows.sort_by(|a, b| b.tx.total_cmp(&a.tx)), } rows_cache = rows; if !rows_cache.is_empty() { - if selected >= rows_cache.len() { selected = rows_cache.len() - 1; } + if selected >= rows_cache.len() { + selected = rows_cache.len() - 1; + } } } @@ -253,7 +280,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let rows_iter = rows_cache.iter().enumerate().map(|(i, r)| { let base = Row::new(vec![ - Span::raw(&r.name), + Span::raw(platform_if_name(r)), Span::raw(human_total(r.total, args.unit)), Span::raw(human_total(r.total_rx, args.unit)), Span::raw(human_total(r.total_tx, args.unit)), @@ -269,7 +296,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { // Table let table = Table::new(rows_iter, [ - Constraint::Length(18), + Constraint::Length(max_name_len), Constraint::Length(14), Constraint::Length(14), Constraint::Length(14), @@ -283,8 +310,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { f.render_widget(table, chunks[0]); // Help - //let help = "Press to quit | cycle sort | rescan interfaces | \u{2191}\u{2193}/w/s select | Enter details | CTRL+C to exit"; - let help = "Press to quit | cycle sort | rescan interfaces | ↑/↓/w/s select | Enter details | (modal) ↑/↓ scroll | CTRL+C to exit"; + let help = "Press to quit | cycle sort | rescan interfaces | ↑/↓/w/s select | Enter details | CTRL+C to exit"; let help_span = Span::styled(help, Style::default().fg(ratatui::style::Color::DarkGray)); let help_row = Row::new(vec![help_span]); let help_table = Table::new( @@ -326,7 +352,7 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { let paragraph = Paragraph::new(Text::raw(detail_text)) .block(block) - .wrap(Wrap { trim: false }) + .wrap(Wrap { trim: false }) .scroll((popup_scroll, 0)); f.render_widget(paragraph, area); @@ -347,6 +373,41 @@ pub fn monitor_interfaces(_cli: &Cli, args: &MonitorArgs) -> Result<()> { res } +/// Get the maximum interface name length for table column width +/// On Windows, consider friendly_name if available +fn get_max_if_name_len(ifs: &[netdev::Interface]) -> u16 { + let max_len: usize = if cfg!(windows) { + ifs.iter() + .map(|it| { + if let Some(fn_name) = &it.friendly_name { + fn_name.len().max(it.name.len()) + } else { + it.name.len() + } + }) + .max() + .unwrap_or(0) + } else { + ifs.iter().map(|it| it.name.len()).max().unwrap_or(0) + }; + (max_len as u16).max(5) +} + +/// Platform-specific interface name specification +/// Linux/Unix: use `name` as-is +/// Windows: use `friendly_name` if available; otherwise, use `name` +fn platform_if_name(row: &RowData) -> &str { + if cfg!(windows) { + if let Some(friendly_name) = &row.friendly_name { + friendly_name + } else { + &row.name + } + } else { + &row.name + } +} + // Total (Bytes or Bits) fn human_total(v_bytes: u64, unit: Unit) -> String { match unit { @@ -445,8 +506,12 @@ fn iface_to_text(iface: &netdev::Interface) -> String { // link speeds (humanized bps) if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { let mut speed = Tree::new(tree_label("Link Speed")); - if let Some(tx) = iface.transmit_speed { speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); } - if let Some(rx) = iface.receive_speed { speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); } + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); + } root.push(speed); } @@ -456,7 +521,9 @@ fn iface_to_text(iface: &netdev::Interface) -> String { // ---- Addresses ---- if !iface.ipv4.is_empty() { let mut ipv4_tree = Tree::new(tree_label("IPv4")); - for net in &iface.ipv4 { ipv4_tree.push(Tree::new(net.to_string())); } + for net in &iface.ipv4 { + ipv4_tree.push(Tree::new(net.to_string())); + } root.push(ipv4_tree); } @@ -464,7 +531,9 @@ fn iface_to_text(iface: &netdev::Interface) -> String { let mut ipv6_tree = Tree::new(tree_label("IPv6")); for (i, net) in iface.ipv6.iter().enumerate() { let mut label = net.to_string(); - if let Some(scope) = iface.ipv6_scope_ids.get(i) { label.push_str(&format!(" (scope_id={})", scope)); } + if let Some(scope) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", scope)); + } ipv6_tree.push(Tree::new(label)); } root.push(ipv6_tree); @@ -473,7 +542,9 @@ fn iface_to_text(iface: &netdev::Interface) -> String { // ---- DNS ---- if !iface.dns_servers.is_empty() { let mut dns_tree = Tree::new(tree_label("DNS")); - for dns in &iface.dns_servers { dns_tree.push(Tree::new(dns.to_string())); } + for dns in &iface.dns_servers { + dns_tree.push(Tree::new(dns.to_string())); + } root.push(dns_tree); } @@ -483,12 +554,16 @@ fn iface_to_text(iface: &netdev::Interface) -> String { gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); if !gw.ipv4.is_empty() { let mut gw4 = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { gw4.push(Tree::new(ip.to_string())); } + for ip in &gw.ipv4 { + gw4.push(Tree::new(ip.to_string())); + } gw_node.push(gw4); } if !gw.ipv6.is_empty() { let mut gw6 = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { gw6.push(Tree::new(ip.to_string())); } + for ip in &gw.ipv6 { + gw6.push(Tree::new(ip.to_string())); + } gw_node.push(gw6); } root.push(gw_node); diff --git a/src/cmd/os.rs b/src/cmd/os.rs index c5cdfd5..829319f 100644 --- a/src/cmd/os.rs +++ b/src/cmd/os.rs @@ -5,8 +5,14 @@ pub fn show_system_net_stack(cli: &Cli) { let sys_info = crate::collector::sys::system_info(); let default_iface_opt = crate::collector::iface::get_default_interface(); match cli.format { - crate::cli::OutputFormat::Tree => crate::renderer::tree::print_system_with_default_iface(&sys_info, default_iface_opt), - crate::cli::OutputFormat::Json => crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt), - crate::cli::OutputFormat::Yaml => crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt), + crate::cli::OutputFormat::Tree => { + crate::renderer::tree::print_system_with_default_iface(&sys_info, default_iface_opt) + } + crate::cli::OutputFormat::Json => { + crate::renderer::json::print_snapshot_json(&sys_info, default_iface_opt) + } + crate::cli::OutputFormat::Yaml => { + crate::renderer::yaml::print_snapshot_yaml(&sys_info, default_iface_opt) + } } } diff --git a/src/cmd/public.rs b/src/cmd/public.rs index e9b6953..ccd7c4f 100644 --- a/src/cmd/public.rs +++ b/src/cmd/public.rs @@ -21,7 +21,7 @@ pub async fn show_public_ip_info(cli: &Cli, args: &PublicArgs) -> Result<()> { let v4: Option; let mut v6: Option = None; - if args.v4_only { + if args.ipv4 { v4 = fetch_ip(&client, IPSTRUCT_V4_URL).await?; } else { let (any_res, v4_res) = tokio::join!( @@ -58,7 +58,11 @@ pub async fn show_public_ip_info(cli: &Cli, args: &PublicArgs) -> Result<()> { /// Fetch IP information from a given URL async fn fetch_ip(client: &Client, url: &str) -> Result> { - let resp = client.get(url).send().await.with_context(|| format!("GET {}", url))?; + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("GET {}", url))?; if !resp.status().is_success() { anyhow::bail!("{} -> HTTP {}", url, resp.status()); } @@ -101,10 +105,10 @@ fn build_public_out(v4: Option, v6: Option) -> PublicOut { let v4i = v4.as_ref().unwrap(); let v6i = v6.as_ref().unwrap(); - let same_asn = v4i.asn == v6i.asn; - let same_as_name = v4i.as_name == v6i.as_name; - let same_cc = v4i.country_code == v6i.country_code; - let same_country = v4i.country_name == v6i.country_name; + let same_asn = v4i.asn == v6i.asn; + let same_as_name = v4i.as_name == v6i.as_name; + let same_cc = v4i.country_code == v6i.country_code; + let same_country = v4i.country_name == v6i.country_name; // If all fields are the same, we can commonize if same_asn && same_as_name && same_cc && same_country { @@ -120,14 +124,20 @@ fn build_public_out(v4: Option, v6: Option) -> PublicOut { ip_addr_dec: v4i.ip_addr_dec.clone(), host_name: v4i.host_name.clone(), network: v4i.network.clone(), - asn: None, as_name: None, country_code: None, country_name: None, + asn: None, + as_name: None, + country_code: None, + country_name: None, }), ipv6: Some(IpSide { ip_addr: v6i.ip_addr.clone(), ip_addr_dec: v6i.ip_addr_dec.clone(), host_name: v6i.host_name.clone(), network: v6i.network.clone(), - asn: None, as_name: None, country_code: None, country_name: None, + asn: None, + as_name: None, + country_code: None, + country_name: None, }), } } else { diff --git a/src/cmd/show.rs b/src/cmd/show.rs index f1b6045..9262a96 100644 --- a/src/cmd/show.rs +++ b/src/cmd/show.rs @@ -9,13 +9,15 @@ pub fn show_interface(cli: &Cli, args: &ShowArgs) { Some(iface) => { // Render output match cli.format { - crate::cli::OutputFormat::Tree => renderer::tree::print_interface_detail_tree(&iface), + crate::cli::OutputFormat::Tree => { + renderer::tree::print_interface_detail_tree(&iface) + } crate::cli::OutputFormat::Json => renderer::json::print_interface_json(&[iface]), crate::cli::OutputFormat::Yaml => renderer::yaml::print_interface_yaml(&[iface]), } - }, + } None => { tracing::error!("Interface '{}' not found", args.iface); } - } + } } diff --git a/src/collector/sys.rs b/src/collector/sys.rs index 75f6a2c..1ad6ca5 100644 --- a/src/collector/sys.rs +++ b/src/collector/sys.rs @@ -1,4 +1,4 @@ -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SysInfo { @@ -9,6 +9,15 @@ pub struct SysInfo { pub codename: String, pub bitness: String, pub architecture: String, + pub proxy: ProxyEnv, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProxyEnv { + pub http: Option, + pub https: Option, + pub all: Option, + pub no_proxy: Option, } pub fn hostname() -> String { @@ -23,11 +32,24 @@ pub fn system_info() -> SysInfo { let info = os_info::get(); let os_type = info.os_type().to_string(); let os_version = info.version().to_string(); - let edition = info.edition().unwrap_or_else(|| "unknown".into()).to_string(); - let codename = info.codename().unwrap_or_else(|| "unknown".into()).to_string(); - let bitness = if cfg!(target_pointer_width = "64") { "64-bit" } else { "32-bit" }.into(); + let edition = info + .edition() + .unwrap_or_else(|| "unknown".into()) + .to_string(); + let codename = info + .codename() + .unwrap_or_else(|| "unknown".into()) + .to_string(); + let bitness = if cfg!(target_pointer_width = "64") { + "64-bit" + } else { + "32-bit" + } + .into(); let architecture = std::env::consts::ARCH.into(); + let proxy = collect_proxy_env(); + SysInfo { hostname, os_type, @@ -36,5 +58,24 @@ pub fn system_info() -> SysInfo { codename, bitness, architecture, + proxy, + } +} + +/// Collect proxy environment variables +pub fn collect_proxy_env() -> ProxyEnv { + // Prefer lowercase, fallback to uppercase + fn pick(key: &str) -> Option { + std::env::var(key.to_lowercase()) + .ok() + .or_else(|| std::env::var(key.to_uppercase()).ok()) + .filter(|s| !s.trim().is_empty()) + } + + ProxyEnv { + http: pick("http_proxy"), + https: pick("https_proxy"), + all: pick("all_proxy"), + no_proxy: pick("no_proxy"), } } diff --git a/src/db/oui.rs b/src/db/oui.rs index 7a73014..970b863 100644 --- a/src/db/oui.rs +++ b/src/db/oui.rs @@ -1,5 +1,5 @@ -use ndb_oui::OuiDb; use anyhow::Result; +use ndb_oui::OuiDb; use std::sync::OnceLock; pub static OUI_DB: OnceLock = OnceLock::new(); diff --git a/src/main.rs b/src/main.rs index cf3716e..a6076b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ use anyhow::Result; use clap::Parser; mod cli; -mod collector; -mod renderer; mod cmd; -mod model; +mod collector; mod db; +mod model; +mod renderer; use cli::{Cli, Command}; @@ -20,22 +20,22 @@ async fn main() -> Result<()> { match &cli.command { None => { cmd::list::show_interfaces(&cli); - }, + } Some(Command::List(args)) => { cmd::list::list_interfaces(&cli, args); - }, + } Some(Command::Show(args)) => { cmd::show::show_interface(&cli, args); - }, + } Some(Command::Os) => { cmd::os::show_system_net_stack(&cli); - }, + } Some(Command::Export(args)) => { cmd::export::export_snapshot(&cli, args)?; - }, + } Some(Command::Monitor(args)) => { cmd::monitor::monitor_interfaces(&cli, args)?; - }, + } Some(Command::Public(args)) => { cmd::public::show_public_ip_info(&cli, args).await?; } diff --git a/src/model/ipinfo.rs b/src/model/ipinfo.rs index bc78c7d..daae67c 100644 --- a/src/model/ipinfo.rs +++ b/src/model/ipinfo.rs @@ -43,4 +43,3 @@ pub struct IpSide { #[serde(skip_serializing_if = "Option::is_none")] pub country_name: Option, } - diff --git a/src/model/mod.rs b/src/model/mod.rs index 3541ab5..2236c1d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,2 @@ -pub mod snapshot; pub mod ipinfo; +pub mod snapshot; diff --git a/src/model/snapshot.rs b/src/model/snapshot.rs index 7654f3d..bb89c88 100644 --- a/src/model/snapshot.rs +++ b/src/model/snapshot.rs @@ -1,5 +1,5 @@ use netdev::Interface; -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; use crate::collector::sys::SysInfo; diff --git a/src/renderer/json.rs b/src/renderer/json.rs index c51afa4..282b3e4 100644 --- a/src/renderer/json.rs +++ b/src/renderer/json.rs @@ -1,5 +1,5 @@ -use netdev::Interface; use crate::{collector::sys::SysInfo, model::snapshot::Snapshot}; +use netdev::Interface; pub fn print_interface_json(ifaces: &[Interface]) { let json = serde_json::to_string_pretty(ifaces).unwrap(); diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index a79d391..7aeaedb 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -1,3 +1,3 @@ -pub mod tree; pub mod json; +pub mod tree; pub mod yaml; diff --git a/src/renderer/tree.rs b/src/renderer/tree.rs index 744619b..905c900 100644 --- a/src/renderer/tree.rs +++ b/src/renderer/tree.rs @@ -1,5 +1,6 @@ -use termtree::Tree; use netdev::{Interface, MacAddr}; +use termtree::Tree; +use url::Url; use crate::{collector::sys::SysInfo, db::oui::is_oui_db_initialized, model::ipinfo::PublicOut}; @@ -11,16 +12,34 @@ pub fn tree_label>(s: S) -> String { pub fn fmt_bps(bps: u64) -> String { const K: f64 = 1_000.0; let b = bps as f64; - if b >= K * K * K { format!("{:.2} Gb/s", b / (K*K*K)) } - else if b >= K * K { format!("{:.2} Mb/s", b / (K*K)) } - else if b >= K { format!("{:.2} Kb/s", b / K) } - else { format!("{} b/s", bps) } + if b >= K * K * K { + format!("{:.2} Gb/s", b / (K * K * K)) + } else if b >= K * K { + format!("{:.2} Mb/s", b / (K * K)) + } else if b >= K { + format!("{:.2} Kb/s", b / K) + } else { + format!("{} b/s", bps) + } } pub fn fmt_flags(flags: u32) -> String { format!("0x{:08X}", flags) } +/// Mask username/password in proxy URL for privacy +fn mask_proxy_url(raw: &str) -> String { + if let Ok(mut url) = Url::parse(raw) { + if url.password().is_some() || !url.username().is_empty() { + let user = url.username().to_string(); + let _ = url.set_username(&user).ok(); + let _ = url.set_password(Some("*****")).ok(); + } + return url.to_string(); + } + raw.to_string() +} + /// Print the network interfaces in a tree structure. pub fn print_interface_tree(ifaces: &[Interface]) { let default: bool = if ifaces.len() == 1 { @@ -40,7 +59,7 @@ pub fn print_interface_tree(ifaces: &[Interface]) { iface.name, if iface.default { " (default)" } else { "" } )); - + node.push(Tree::new(format!("Index: {}", iface.index))); if let Some(fn_name) = &iface.friendly_name { @@ -166,8 +185,12 @@ pub fn print_interface_detail_tree(iface: &Interface) { // link speeds (humanized bps) if iface.transmit_speed.is_some() || iface.receive_speed.is_some() { let mut speed = Tree::new(tree_label("Link Speed")); - if let Some(tx) = iface.transmit_speed { speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); } - if let Some(rx) = iface.receive_speed { speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); } + if let Some(tx) = iface.transmit_speed { + speed.push(Tree::new(format!("TX: {}", fmt_bps(tx)))); + } + if let Some(rx) = iface.receive_speed { + speed.push(Tree::new(format!("RX: {}", fmt_bps(rx)))); + } root.push(speed); } @@ -177,7 +200,9 @@ pub fn print_interface_detail_tree(iface: &Interface) { // ---- Addresses ---- if !iface.ipv4.is_empty() { let mut ipv4_tree = Tree::new(tree_label("IPv4")); - for net in &iface.ipv4 { ipv4_tree.push(Tree::new(net.to_string())); } + for net in &iface.ipv4 { + ipv4_tree.push(Tree::new(net.to_string())); + } root.push(ipv4_tree); } @@ -185,7 +210,9 @@ pub fn print_interface_detail_tree(iface: &Interface) { let mut ipv6_tree = Tree::new(tree_label("IPv6")); for (i, net) in iface.ipv6.iter().enumerate() { let mut label = net.to_string(); - if let Some(scope) = iface.ipv6_scope_ids.get(i) { label.push_str(&format!(" (scope_id={})", scope)); } + if let Some(scope) = iface.ipv6_scope_ids.get(i) { + label.push_str(&format!(" (scope_id={})", scope)); + } ipv6_tree.push(Tree::new(label)); } root.push(ipv6_tree); @@ -194,7 +221,9 @@ pub fn print_interface_detail_tree(iface: &Interface) { // ---- DNS ---- if !iface.dns_servers.is_empty() { let mut dns_tree = Tree::new(tree_label("DNS")); - for dns in &iface.dns_servers { dns_tree.push(Tree::new(dns.to_string())); } + for dns in &iface.dns_servers { + dns_tree.push(Tree::new(dns.to_string())); + } root.push(dns_tree); } @@ -204,12 +233,16 @@ pub fn print_interface_detail_tree(iface: &Interface) { gw_node.push(Tree::new(format!("MAC: {}", gw.mac_addr))); if !gw.ipv4.is_empty() { let mut gw4 = Tree::new(tree_label("IPv4")); - for ip in &gw.ipv4 { gw4.push(Tree::new(ip.to_string())); } + for ip in &gw.ipv4 { + gw4.push(Tree::new(ip.to_string())); + } gw_node.push(gw4); } if !gw.ipv6.is_empty() { let mut gw6 = Tree::new(tree_label("IPv6")); - for ip in &gw.ipv6 { gw6.push(Tree::new(ip.to_string())); } + for ip in &gw.ipv6 { + gw6.push(Tree::new(ip.to_string())); + } gw_node.push(gw6); } root.push(gw_node); @@ -227,16 +260,76 @@ pub fn print_interface_detail_tree(iface: &Interface) { } pub fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option) { - let mut root = Tree::new(tree_label(format!("System Information on {}", sys.hostname))); + let mut root = Tree::new(tree_label(format!( + "System Information on {}", + sys.hostname + ))); // ---- System ---- let mut sys_node = Tree::new(tree_label("System")); sys_node.push(Tree::new(tree_label(format!("OS Type: {}", sys.os_type)))); - sys_node.push(Tree::new(tree_label(format!("Version: {}", sys.os_version)))); + sys_node.push(Tree::new(tree_label(format!( + "Version: {}", + sys.os_version + )))); sys_node.push(Tree::new(tree_label(format!("Edition: {}", sys.edition)))); sys_node.push(Tree::new(tree_label(format!("Codename: {}", sys.codename)))); sys_node.push(Tree::new(tree_label(format!("Bitness: {}", sys.bitness)))); - sys_node.push(Tree::new(tree_label(format!("Architecture: {}", sys.architecture)))); + sys_node.push(Tree::new(tree_label(format!( + "Architecture: {}", + sys.architecture + )))); + + // ---- Proxy (env) ---- + let px = crate::collector::sys::collect_proxy_env(); + let mut px_node = Tree::new(tree_label("Proxy (env)")); + px_node.push(Tree::new(format!( + "HTTP_PROXY: {}", + px.http + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + px_node.push(Tree::new(format!( + "HTTPS_PROXY: {}", + px.https + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + px_node.push(Tree::new(format!( + "ALL_PROXY: {}", + px.all + .as_deref() + .map(mask_proxy_url) + .unwrap_or_else(|| "(none)".into()) + ))); + if let Some(np) = px.no_proxy.as_deref() { + let mut np_node = Tree::new(tree_label("NO_PROXY")); + // Split by comma and trim spaces, ignore empty parts + for (i, part) in np + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .enumerate() + { + // Limit to first 20 entries + if i < 20 { + np_node.push(Tree::new(part.to_string())); + } else { + np_node.push(Tree::new(format!( + "(+{} more)", + np.split(',').count().saturating_sub(20) + ))); + break; + } + } + px_node.push(np_node); + } else { + px_node.push(Tree::new(tree_label("NO_PROXY: (none)"))); + } + sys_node.push(px_node); + root.push(sys_node); // ---- Default Interface (optional) ---- @@ -252,7 +345,10 @@ pub fn print_system_with_default_iface(sys: &SysInfo, default_iface: Option