From a543458658f2363ac548de3a11c7f2085e3a3082 Mon Sep 17 00:00:00 2001 From: Kiyomichi Kosaka Date: Sat, 9 Aug 2025 18:09:37 +0200 Subject: [PATCH] First full working version. --- .githooks/install-hooks.sh | 23 ++ .githooks/pre-commit | 94 +++++ .github/workflows/ci.yml | 80 ++++ .github/workflows/release.yml | 62 +++ .gitignore | 71 ++++ .pre-commit-config.yaml | 39 ++ Cargo.toml | 89 +++++ README.md | 214 +++++++++++ rustfmt.toml | 14 + src/cli/mod.rs | 236 ++++++++++++ src/dns/categories.rs | 378 +++++++++++++++++++ src/dns/mod.rs | 689 ++++++++++++++++++++++++++++++++++ src/dns/queries.rs | 147 ++++++++ src/lib.rs | 11 + src/main.rs | 370 ++++++++++++++++++ src/mtu/mod.rs | 132 +++++++ src/network/icmp.rs | 51 +++ src/network/mod.rs | 106 ++++++ src/network/tcp.rs | 43 +++ src/network/udp.rs | 63 ++++ src/utils/mod.rs | 83 ++++ src/utils/tests.rs | 66 ++++ tests/integration_tests.rs | 177 +++++++++ 23 files changed, 3238 insertions(+) create mode 100755 .githooks/install-hooks.sh create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 rustfmt.toml create mode 100644 src/cli/mod.rs create mode 100644 src/dns/categories.rs create mode 100644 src/dns/mod.rs create mode 100644 src/dns/queries.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/mtu/mod.rs create mode 100644 src/network/icmp.rs create mode 100644 src/network/mod.rs create mode 100644 src/network/tcp.rs create mode 100644 src/network/udp.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/tests.rs create mode 100644 tests/integration_tests.rs diff --git a/.githooks/install-hooks.sh b/.githooks/install-hooks.sh new file mode 100755 index 0000000..fd86bc4 --- /dev/null +++ b/.githooks/install-hooks.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Script to install git hooks + +HOOK_DIR="$(git rev-parse --git-dir)/hooks" +SCRIPT_DIR="$(dirname "$0")" + +echo "Installing git hooks..." + +# Copy pre-commit hook +if [ -f "$SCRIPT_DIR/pre-commit" ]; then + cp "$SCRIPT_DIR/pre-commit" "$HOOK_DIR/pre-commit" + chmod +x "$HOOK_DIR/pre-commit" + echo "βœ“ Installed pre-commit hook" +else + echo "βœ— pre-commit hook not found" + exit 1 +fi + +echo "βœ… Git hooks installed successfully!" +echo +echo "To bypass hooks for a commit, use: git commit --no-verify" +echo "To uninstall hooks, delete files in: $HOOK_DIR" \ No newline at end of file diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1a2a2f0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,94 @@ +#!/bin/bash + +set -e + +echo "πŸš€ Running pre-commit checks..." + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}βœ“${NC} $1" +} + +print_error() { + echo -e "${RED}βœ—${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +# Check if cargo is available +if ! command -v cargo &> /dev/null; then + print_error "Cargo is not installed or not in PATH" + exit 1 +fi + +# Check for staged Rust files +staged_rust_files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(rs)$' || true) + +if [ -z "$staged_rust_files" ]; then + print_warning "No Rust files staged for commit" + exit 0 +fi + +echo "Staged Rust files:" +echo "$staged_rust_files" +echo + +# 1. Check formatting +echo "πŸ“ Checking code formatting..." +if ! cargo fmt -- --check; then + print_error "Code formatting check failed!" + echo "Run 'cargo fmt' to fix formatting issues" + exit 1 +fi +print_status "Code formatting is correct" + +# 2. Run Clippy +echo "πŸ“Ž Running Clippy linter..." +if ! cargo clippy --all-features --all-targets -- -D warnings; then + print_error "Clippy found issues!" + echo "Fix the issues above or run 'cargo clippy --fix' for automatic fixes" + exit 1 +fi +print_status "Clippy checks passed" + +# 3. Run tests +echo "πŸ§ͺ Running tests..." +if ! cargo test --all-features; then + print_error "Tests failed!" + echo "Fix failing tests before committing" + exit 1 +fi +print_status "All tests passed" + +# 4. Check for security vulnerabilities (optional - only if cargo-audit is installed) +if command -v cargo-audit &> /dev/null; then + echo "πŸ”’ Running security audit..." + if ! cargo audit; then + print_error "Security vulnerabilities found!" + echo "Review and fix security issues before committing" + exit 1 + fi + print_status "Security audit passed" +else + print_warning "cargo-audit not installed. Run 'cargo install cargo-audit' to enable security checks" +fi + +# 5. Check that the project builds +echo "πŸ”¨ Building project..." +if ! cargo build --all-features; then + print_error "Build failed!" + exit 1 +fi +print_status "Build successful" + +echo +echo -e "${GREEN}πŸŽ‰ All pre-commit checks passed!${NC}" +echo "Proceeding with commit..." \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..906e235 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + rust: + - stable + - beta + - nightly + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + - uses: Swatinem/rust-cache@v2 + - name: Run tests + run: cargo test --all-features + + fmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Run clippy + run: cargo clippy --all-features --all-targets -- -D warnings + + security_audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v1.4.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: Swatinem/rust-cache@v2 + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + - name: Generate coverage + run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: lcov.info + fail_ci_if_error: true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..44d9dad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,62 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: nettest + asset_name: nettest-linux-x86_64 + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: nettest.exe + asset_name: nettest-windows-x86_64.exe + - os: macos-latest + target: x86_64-apple-darwin + artifact_name: nettest + asset_name: nettest-macos-x86_64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --release --target ${{ matrix.target }} + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.asset_name }} + path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }} + + release: + name: Release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Download artifacts + uses: actions/download-artifact@v3 + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + nettest-linux-x86_64/nettest + nettest-windows-x86_64.exe/nettest.exe + nettest-macos-x86_64/nettest + body_path: CHANGELOG.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18f0e79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# Rust artifacts +/target/ +Cargo.lock + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# OS generated files +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + +# Logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Flamegraph output +flamegraph.svg +perf.data* + +# MacOS +.DS_Store +.AppleDouble +.LSOverride +Icon + +# Temporary files +*.tmp +*.temp + +# Backup files +*.bak +*.backup + +# Test artifacts +test-results/ +test-output/ + +# Benchmarks +criterion/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2253321 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,39 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: check-case-conflict + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: detect-private-key + + - repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + args: ['--verbose', '--'] + - id: cargo-check + args: ['--all-features'] + - id: clippy + args: ['--all-features', '--all-targets', '--', '-D', 'warnings'] + + - repo: local + hooks: + - id: cargo-test + name: cargo test + entry: cargo test --all-features + language: system + types: [rust] + pass_filenames: false + - id: cargo-audit + name: cargo audit + entry: bash -c 'if command -v cargo-audit &> /dev/null; then cargo audit; else echo "cargo-audit not installed, skipping security audit"; fi' + language: system + types: [rust] + pass_filenames: false \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..68f773b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,89 @@ +[package] +name = "nettest" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" +authors = ["Network Test Tool"] +description = "A comprehensive network connectivity and DNS testing CLI tool" +license = "MIT OR Apache-2.0" +repository = "https://github.com/example/nettest" +keywords = ["network", "dns", "testing", "connectivity", "cli"] +categories = ["command-line-utilities", "network-programming"] + +[[bin]] +name = "nettest" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +tokio = { version = "1.0", features = ["full"] } +trust-dns-resolver = "0.23" +trust-dns-client = "0.23" +socket2 = "0.5" +pnet = "0.34" +anyhow = "1.0" +thiserror = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" +env_logger = "0.10" +colored = "2.0" +indicatif = "0.17" +rand = "0.8" +futures = "0.3" +libc = "0.2" + +[dev-dependencies] +tokio-test = "0.4" +criterion = "0.5" +proptest = "1.0" +pretty_assertions = "1.0" + +[lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +# Allow some pedantic lints that can be overly strict +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +# Allow multiple crate versions (common in dependency trees) +multiple_crate_versions = "allow" +# Allow some formatting preferences +uninlined_format_args = "allow" +# Allow some code style preferences +use_self = "allow" +wildcard_imports = "allow" +# Allow missing const for builder patterns +missing_const_for_fn = "allow" +# Allow casting precision loss for statistical calculations +cast_precision_loss = "allow" +# Allow redundant closures for clarity +redundant_closure = "allow" +# Allow inefficient to_string for readability +inefficient_to_string = "allow" +# Allow ignored unit patterns +ignored_unit_patterns = "allow" +# Allow needless borrows for readability +needless_borrows_for_generic_args = "allow" +# Allow match same arms for clear documentation +match_same_arms = "allow" +# Allow redundant closures for method calls +redundant_closure_for_method_calls = "allow" +# Allow functions with many lines for complex logic +too_many_lines = "allow" +# Allow single match else patterns +single_match_else = "allow" +# Allow redundant pattern matching for clarity +redundant_pattern_matching = "allow" +# Allow unused self in methods that may need it later +unused_self = "allow" +# Allow single component path imports for clarity +single_component_path_imports = "allow" +# Allow complex boolean expressions in tests +overly_complex_bool_expr = "allow" +# Allow map_unwrap_or patterns +map_unwrap_or = "allow" +# Allow collapsible else if patterns +collapsible_else_if = "allow" diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdc3199 --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# NetTest - Network Connectivity Testing Tool + +A comprehensive command-line tool written in Rust for testing network connectivity and DNS resolution across various dimensions. + +## Features + +### Network Testing +- **IPv4 and IPv6 support** - Test connectivity using both IP versions +- **Multiple protocols** - Support for TCP, UDP, and ICMP +- **Port testing** - Test common ports and custom port ranges +- **Timeout configuration** - Configurable timeouts for all tests + +### MTU Discovery +- **Binary search MTU discovery** - Efficiently find the maximum MTU size +- **Common MTU testing** - Test standard MTU sizes (68, 576, 1280, 1500, 4464, 9000) +- **Custom range testing** - Test specific MTU ranges +- **IPv4 and IPv6 support** - MTU discovery for both IP versions + +### DNS Testing +- **Comprehensive record types** - A, AAAA, MX, NS, TXT, CNAME, SOA, PTR, and more +- **Multiple DNS servers** - Test against Google, Cloudflare, Quad9, OpenDNS, and others +- **TCP and UDP queries** - Support for both DNS transport protocols +- **Large query testing** - Test handling of large DNS responses +- **International domains** - Support for IDN (Internationalized Domain Names) + +### Domain Category Testing +- **Normal websites** - Test legitimate, commonly used sites +- **Ad networks** - Test advertising and tracking domains +- **Spam domains** - Test temporary email and spam-associated domains +- **Adult content** - Test adult content sites (often filtered) +- **Malicious domains** - Test known malicious/phishing domains +- **Social media** - Test major social media platforms +- **Streaming services** - Test video and music streaming sites +- **Gaming platforms** - Test gaming services and platforms +- **News websites** - Test major news and media sites + +### DNS Filtering Analysis +- **Filter effectiveness** - Analyze how well DNS filtering is working +- **Category-based analysis** - See which categories are being blocked +- **Detailed reporting** - Get statistics on resolution success rates + +## Installation + +```bash +# Clone the repository +git clone +cd nettest + +# Build the project +cargo build --release + +# Install globally (optional) +cargo install --path . +``` + +## Usage + +### Basic Commands + +```bash +# Run comprehensive tests on a target +nettest full google.com + +# Test TCP connectivity +nettest network tcp google.com --port 80 + +# Test UDP connectivity +nettest network udp 8.8.8.8 --port 53 + +# Ping test +nettest network ping google.com --count 4 + +# Test common ports +nettest network ports google.com --protocol tcp + +# DNS query +nettest dns query google.com --record-type a + +# Test DNS servers +nettest dns servers google.com + +# Test domain categories +nettest dns categories --category normal + +# MTU discovery +nettest mtu discover google.com + +# Test common MTU sizes +nettest mtu common google.com +``` + +### Advanced Options + +```bash +# Specify IP version +nettest network tcp google.com --ip-version v4 +nettest network tcp google.com --ip-version v6 +nettest network tcp google.com --ip-version both + +# Custom timeout +nettest --timeout 10 network tcp google.com + +# JSON output +nettest --json dns query google.com + +# Verbose logging +nettest --verbose full google.com + +# DNS query with specific server +nettest dns query google.com --server 8.8.8.8:53 --tcp + +# Custom MTU range +nettest mtu range google.com --min 1000 --max 1500 +``` + +### Domain Category Testing + +Test different categories of domains to analyze DNS filtering: + +```bash +# Test normal websites +nettest dns categories --category normal + +# Test ad networks +nettest dns categories --category ads + +# Test all categories +nettest dns categories --category all + +# DNS filtering effectiveness +nettest dns filtering +``` + +### Comprehensive Testing + +The `full` command runs a comprehensive suite of tests: + +```bash +# Full test suite for a domain +nettest full example.com + +# Full test with specific IP version +nettest full example.com --ip-version v4 +``` + +This includes: +- TCP and UDP connectivity tests +- ICMP ping tests +- MTU discovery +- DNS resolution tests +- DNS server tests + +## Output Formats + +### Human-readable (default) +Colored, formatted output suitable for terminal viewing. + +### JSON +Machine-readable JSON output for integration with other tools: + +```bash +nettest --json dns query google.com +``` + +## Requirements + +- Rust 1.70 or later +- Root/administrator privileges may be required for: + - ICMP ping tests + - Raw socket operations + - MTU discovery + +## Testing + +Run the test suite: + +```bash +# Unit tests +cargo test + +# Integration tests +cargo test --test integration_tests + +# All tests with verbose output +cargo test -- --nocapture +``` + +## Architecture + +The tool is organized into several modules: + +- **cli** - Command-line argument parsing and interface +- **network** - TCP, UDP, and ICMP connectivity testing +- **dns** - DNS resolution and query testing +- **mtu** - MTU discovery and testing +- **utils** - Common utilities and error handling + +## Security Considerations + +This tool is designed for defensive security testing and network diagnostics. It: + +- Tests legitimate connectivity to verify network functionality +- Analyzes DNS filtering effectiveness +- Discovers network path characteristics +- Does not attempt to exploit or attack systems +- Respects rate limits and timeouts + +## License + +[Add your license here] + +## Contributing + +[Add contribution guidelines here] \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..83c6641 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,14 @@ +# Stable Rust rustfmt configuration +max_width = 100 +hard_tabs = false +tab_spaces = 4 +newline_style = "Unix" +use_small_heuristics = "Default" +edition = "2021" +merge_derives = true +use_try_shorthand = false +use_field_init_shorthand = false +force_explicit_abi = true +reorder_imports = true +reorder_modules = true +remove_nested_parens = true \ No newline at end of file diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..d0ec1fd --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,236 @@ +use clap::{Parser, Subcommand, ValueEnum}; +use std::net::SocketAddr; + +#[derive(Parser)] +#[command(name = "nettest")] +#[command(about = "A comprehensive network connectivity and DNS testing CLI tool")] +#[command(version)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + #[arg(short, long, global = true)] + pub verbose: bool, + + #[arg(short, long, global = true, default_value = "5")] + pub timeout: u64, + + #[arg(long, global = true)] + pub json: bool, +} + +#[derive(Subcommand)] +pub enum Commands { + #[command(about = "Test network connectivity")] + Network { + #[command(subcommand)] + command: NetworkCommands, + }, + #[command(about = "Test DNS resolution")] + Dns { + #[command(subcommand)] + command: DnsCommands, + }, + #[command(about = "Discover MTU sizes")] + Mtu { + #[command(subcommand)] + command: MtuCommands, + }, + #[command(about = "Run comprehensive tests")] + Full { + target: String, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, +} + +#[derive(Subcommand)] +pub enum NetworkCommands { + #[command(about = "Test TCP connectivity")] + Tcp { + target: String, + #[arg(short, long, default_value = "80")] + port: u16, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, + #[command(about = "Test UDP connectivity")] + Udp { + target: String, + #[arg(short, long, default_value = "53")] + port: u16, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, + #[command(about = "Test ICMP ping")] + Ping { + target: String, + #[arg(short, long, default_value = "4")] + count: u32, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, + #[command(about = "Test common ports")] + Ports { + target: String, + #[arg(short, long, value_enum, default_value = "tcp")] + protocol: ProtocolArg, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, +} + +#[derive(Subcommand)] +pub enum DnsCommands { + #[command(about = "Query specific DNS record")] + Query { + domain: String, + #[arg(short, long, value_enum, default_value = "a")] + record_type: RecordTypeArg, + #[arg(short, long)] + server: Option, + #[arg(long)] + tcp: bool, + }, + #[command(about = "Test DNS servers")] + Servers { + domain: String, + #[arg(short, long, value_enum, default_value = "a")] + record_type: RecordTypeArg, + }, + #[command(about = "Test domain categories")] + Categories { + #[arg(short, long, value_enum)] + category: Option, + #[arg(short, long, value_enum, default_value = "a")] + record_type: RecordTypeArg, + }, + #[command(about = "Test DNS filtering effectiveness")] + Filtering, + #[command(about = "Show system DNS configuration")] + Debug, + #[command(about = "Comprehensive DNS tests")] + Comprehensive { domain: String }, + #[command(about = "Test large DNS queries")] + Large { domain: String }, +} + +#[derive(Subcommand)] +pub enum MtuCommands { + #[command(about = "Discover MTU for target")] + Discover { + target: String, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, + #[command(about = "Test common MTU sizes")] + Common { + target: String, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, + #[command(about = "Test custom MTU range")] + Range { + target: String, + #[arg(short, long, default_value = "68")] + min: u16, + #[arg(short, long, default_value = "1500")] + max: u16, + #[arg(short, long, value_enum, default_value = "both")] + ip_version: IpVersionArg, + }, +} + +#[derive(Clone, ValueEnum)] +pub enum IpVersionArg { + V4, + V6, + Both, +} + +impl IpVersionArg { + pub fn to_versions(&self) -> Vec { + match self { + IpVersionArg::V4 => vec![crate::network::IpVersion::V4], + IpVersionArg::V6 => vec![crate::network::IpVersion::V6], + IpVersionArg::Both => { + vec![crate::network::IpVersion::V4, crate::network::IpVersion::V6] + } + } + } +} + +#[derive(Clone, ValueEnum)] +pub enum ProtocolArg { + Tcp, + Udp, + Both, +} + +#[derive(Clone, ValueEnum)] +pub enum RecordTypeArg { + A, + AAAA, + MX, + NS, + TXT, + CNAME, + SOA, + PTR, + All, +} + +impl RecordTypeArg { + pub fn to_record_type(&self) -> Vec { + match self { + RecordTypeArg::A => vec![trust_dns_client::rr::RecordType::A], + RecordTypeArg::AAAA => vec![trust_dns_client::rr::RecordType::AAAA], + RecordTypeArg::MX => vec![trust_dns_client::rr::RecordType::MX], + RecordTypeArg::NS => vec![trust_dns_client::rr::RecordType::NS], + RecordTypeArg::TXT => vec![trust_dns_client::rr::RecordType::TXT], + RecordTypeArg::CNAME => vec![trust_dns_client::rr::RecordType::CNAME], + RecordTypeArg::SOA => vec![trust_dns_client::rr::RecordType::SOA], + RecordTypeArg::PTR => vec![trust_dns_client::rr::RecordType::PTR], + RecordTypeArg::All => vec![ + trust_dns_client::rr::RecordType::A, + trust_dns_client::rr::RecordType::AAAA, + trust_dns_client::rr::RecordType::MX, + trust_dns_client::rr::RecordType::NS, + trust_dns_client::rr::RecordType::TXT, + trust_dns_client::rr::RecordType::CNAME, + trust_dns_client::rr::RecordType::SOA, + ], + } + } +} + +#[derive(Clone, ValueEnum)] +pub enum CategoryArg { + Normal, + Ads, + Spam, + Adult, + Malicious, + Social, + Streaming, + Gaming, + News, + All, +} + +impl CategoryArg { + pub fn to_categories(&self) -> Vec<&'static crate::dns::categories::DomainCategory> { + match self { + CategoryArg::Normal => vec![&crate::dns::categories::NORMAL_SITES], + CategoryArg::Ads => vec![&crate::dns::categories::AD_SITES], + CategoryArg::Spam => vec![&crate::dns::categories::SPAM_SITES], + CategoryArg::Adult => vec![&crate::dns::categories::ADULT_SITES], + CategoryArg::Malicious => vec![&crate::dns::categories::MALICIOUS_SITES], + CategoryArg::Social => vec![&crate::dns::categories::SOCIAL_MEDIA], + CategoryArg::Streaming => vec![&crate::dns::categories::STREAMING_SITES], + CategoryArg::Gaming => vec![&crate::dns::categories::GAMING_SITES], + CategoryArg::News => vec![&crate::dns::categories::NEWS_SITES], + CategoryArg::All => crate::dns::categories::ALL_CATEGORIES.iter().collect(), + } + } +} diff --git a/src/dns/categories.rs b/src/dns/categories.rs new file mode 100644 index 0000000..fafd5ae --- /dev/null +++ b/src/dns/categories.rs @@ -0,0 +1,378 @@ +use super::DnsTest; +use crate::utils::TestResult; +use trust_dns_client::rr::RecordType; + +#[derive(Clone)] +pub struct DomainCategory { + pub name: &'static str, + pub domains: &'static [&'static str], + pub description: &'static str, +} + +pub const NORMAL_SITES: DomainCategory = DomainCategory { + name: "Normal Sites", + domains: &[ + "google.com", + "github.com", + "stackoverflow.com", + "wikipedia.org", + "microsoft.com", + "apple.com", + "amazon.com", + "cloudflare.com", + "mozilla.org", + "rust-lang.org", + ], + description: "Legitimate, commonly used websites", +}; + +pub const AD_SITES: DomainCategory = DomainCategory { + name: "Ad Networks", + domains: &[ + "doubleclick.net", + "googlesyndication.com", + "googleadservices.com", + "facebook.com", + "googletagmanager.com", + "amazon-adsystem.com", + "adsystem.amazon.com", + "outbrain.com", + "taboola.com", + "criteo.com", + ], + description: "Advertising networks and tracking domains", +}; + +pub const SPAM_SITES: DomainCategory = DomainCategory { + name: "Known Spam Domains", + domains: &[ + // Using domains from known spam lists (these should be blocked by many DNS filters) + "guerrillamail.com", + "10minutemail.com", + "tempmail.org", + "mailinator.com", + "spam4.me", + "trashmail.com", + "yopmail.com", + "tempinbox.com", + "throwaway.email", + "temp-mail.org", + ], + description: "Temporary email services often associated with spam", +}; + +pub const ADULT_SITES: DomainCategory = DomainCategory { + name: "Adult Content", + domains: &[ + // Using well-known adult sites that are often blocked by family filters + // These are legitimate businesses but often filtered + "pornhub.com", + "xvideos.com", + "xnxx.com", + "redtube.com", + "youporn.com", + "tube8.com", + "xtube.com", + "spankbang.com", + "xhamster.com", + "beeg.com", + ], + description: "Adult content websites often blocked by family filters", +}; + +pub const MALICIOUS_SITES: DomainCategory = DomainCategory { + name: "Known Malicious Domains", + domains: &[ + // Using domains from threat intelligence feeds (these should be blocked) + // Note: These might not resolve or might be sinkholed + "malware.testcategory.com", + "phishing.testcategory.com", + "badware.com", + "example-malware.com", + "test-phishing.com", + "fake-bank-site.com", + "malicious-download.com", + "virus-test.com", + "trojan-test.com", + "ransomware-test.com", + ], + description: "Test domains for malicious content detection", +}; + +pub const SOCIAL_MEDIA: DomainCategory = DomainCategory { + name: "Social Media", + domains: &[ + "facebook.com", + "twitter.com", + "instagram.com", + "linkedin.com", + "youtube.com", + "tiktok.com", + "snapchat.com", + "pinterest.com", + "reddit.com", + "discord.com", + ], + description: "Social media platforms", +}; + +pub const STREAMING_SITES: DomainCategory = DomainCategory { + name: "Streaming Services", + domains: &[ + "netflix.com", + "hulu.com", + "disney.com", + "primevideo.com", + "spotify.com", + "twitch.tv", + "youtube.com", + "crunchyroll.com", + "funimation.com", + "hbomax.com", + ], + description: "Video and music streaming services", +}; + +pub const GAMING_SITES: DomainCategory = DomainCategory { + name: "Gaming Platforms", + domains: &[ + "steam.com", + "epicgames.com", + "battle.net", + "origin.com", + "uplay.com", + "roblox.com", + "minecraft.net", + "ea.com", + "activision.com", + "nintendo.com", + ], + description: "Gaming platforms and services", +}; + +pub const NEWS_SITES: DomainCategory = DomainCategory { + name: "News Websites", + domains: &[ + "cnn.com", + "bbc.com", + "reuters.com", + "nytimes.com", + "washingtonpost.com", + "theguardian.com", + "npr.org", + "ap.org", + "bloomberg.com", + "wsj.com", + ], + description: "News and media websites", +}; + +pub const ALL_CATEGORIES: &[DomainCategory] = &[ + NORMAL_SITES, + AD_SITES, + SPAM_SITES, + ADULT_SITES, + MALICIOUS_SITES, + SOCIAL_MEDIA, + STREAMING_SITES, + GAMING_SITES, + NEWS_SITES, +]; + +pub async fn test_domain_category( + category: &DomainCategory, + record_type: RecordType, +) -> Vec { + let mut results = Vec::new(); + + for &domain in category.domains { + let test = DnsTest::new(domain.to_string(), record_type); + + // Use appropriate testing method based on category type + let mut test_result = match category.name { + "Known Malicious Domains" => test.run_security_test().await, + "Ad Networks" | "Known Spam Domains" | "Adult Content" => { + test.run_filtering_test().await + } + _ => test.run().await, + }; + + test_result.test_name = format!("{} - {} ({:?})", category.name, domain, record_type); + results.push(test_result); + } + + results +} + +pub async fn test_all_categories(record_type: RecordType) -> Vec { + let mut results = Vec::new(); + + for category in ALL_CATEGORIES { + let category_results = test_domain_category(category, record_type).await; + results.extend(category_results); + } + + results +} + +pub async fn comprehensive_category_test() -> Vec { + let mut results = Vec::new(); + + // Test A records for all categories + results.extend(test_all_categories(RecordType::A).await); + + // Test AAAA records for normal sites only (to avoid too many tests) + results.extend(test_domain_category(&NORMAL_SITES, RecordType::AAAA).await); + + // Test MX records for normal sites + results.extend(test_domain_category(&NORMAL_SITES, RecordType::MX).await); + + results +} + +pub async fn test_dns_filtering_effectiveness() -> Vec { + let mut results = Vec::new(); + + // Test if DNS filtering is working by checking resolution of different categories + let filter_test_categories = [ + (&AD_SITES, "Ad blocking test"), + (&SPAM_SITES, "Spam filtering test"), + (&ADULT_SITES, "Adult content filtering test"), + (&MALICIOUS_SITES, "Malware filtering test"), + ]; + + for (category, test_name) in &filter_test_categories { + let category_results = test_domain_category(category, RecordType::A).await; + + // Analyze results based on category type + let total_domains = category_results.len(); + let (blocked_domains, resolved_domains, concerning_domains) = if category.name + == "Known Malicious Domains" + { + // For malicious domains, categorize the results + let dns_blocked = category_results + .iter() + .filter(|result| result.details.contains("πŸ›‘οΈ BLOCKED")) + .count(); + let sinkholed = category_results + .iter() + .filter(|result| result.details.contains("πŸ•³οΈ SINKHOLED")) + .count(); + let total_blocked = dns_blocked + sinkholed; + let concerning = category_results + .iter() + .filter(|result| { + result.details.contains("⚠️ RESOLVED") || result.details.contains("⚠️ MIXED") + }) + .count(); + let other_resolved = total_domains - total_blocked - concerning; + (total_blocked, other_resolved, concerning) + } else if matches!( + category.name, + "Ad Networks" | "Known Spam Domains" | "Adult Content" + ) { + // For filtering categories, count based on filtering results + let dns_filtered = category_results + .iter() + .filter(|result| result.details.contains("🚫 FILTERED")) + .count(); + let sinkholed = category_results + .iter() + .filter(|result| result.details.contains("πŸ•³οΈ SINKHOLED")) + .count(); + let total_filtered = dns_filtered + sinkholed; + let accessible = category_results + .iter() + .filter(|result| result.details.contains("πŸ“‘ ACCESSIBLE")) + .count(); + (total_filtered, accessible, 0) + } else { + // For other categories, traditional success/failure counting + let blocked = category_results + .iter() + .filter(|result| !result.success) + .count(); + let resolved = total_domains - blocked; + (blocked, resolved, 0) + }; + + let summary_result = if category.name == "Known Malicious Domains" { + let security_status = if concerning_domains > 0 { + format!( + "⚠️ SECURITY CONCERN: {} potentially malicious domains resolved successfully", + concerning_domains + ) + } else if blocked_domains > resolved_domains { + format!( + "πŸ›‘οΈ GOOD SECURITY: {:.1}% of malicious domains blocked", + (blocked_domains as f64 / total_domains as f64) * 100.0 + ) + } else { + format!( + "⚠️ WEAK FILTERING: Only {:.1}% of malicious domains blocked", + (blocked_domains as f64 / total_domains as f64) * 100.0 + ) + }; + + TestResult::new(format!( + "Security Analysis: {} blocked, {} resolved, {} concerning", + blocked_domains, resolved_domains, concerning_domains + )) + .success(std::time::Duration::from_millis(0), security_status) + } else { + TestResult::new(format!( + "{}: {} resolved, {} blocked", + test_name, resolved_domains, blocked_domains + )) + .success( + std::time::Duration::from_millis(0), + format!( + "Blocking rate: {:.1}%", + (blocked_domains as f64 / total_domains as f64) * 100.0 + ), + ) + }; + + results.push(summary_result); + results.extend(category_results); + } + + results +} + +/// Provide explanation of what DNS filtering results mean +pub fn explain_dns_filtering_results() -> String { + r#" +DNS FILTERING ANALYSIS EXPLANATION: + +πŸ›‘οΈ BLOCKED results for malicious domains = SECURITY SUCCESS + - Domain was blocked at DNS level (good!) + - Possible blocking levels: Router, ISP, DNS service (Cloudflare, Quad9), OS + +πŸ•³οΈ SINKHOLED results = ADVANCED FILTERING SUCCESS + - Domain redirected to harmless "sinkhole" IP addresses + - Common sinkholes: 0.0.0.0, 127.0.0.1, router IPs + - More sophisticated than simple blocking + +⚠️ RESOLVED results for malicious domains = POTENTIAL SECURITY CONCERN + - Domain resolved to real IP addresses + - Could indicate: No filtering, outdated blocklists, or domain not yet flagged + +⚑ PARTIAL SINKHOLE = MIXED FILTERING + - Some IPs sinkholed, others real (inconsistent filtering) + +For other categories (Adult, Ads, etc.): + πŸ“‘ ACCESSIBLE = Normal (expected behavior without filtering) + 🚫 FILTERED = Content filtering active (family filters, ad blockers, etc.) + πŸ•³οΈ SINKHOLED = Advanced filtering (DNS redirect to safe IPs) + +Common sinkhole IP addresses: + β€’ 0.0.0.0 - Universal "null route" + β€’ 127.0.0.1 - Localhost redirect + β€’ 192.168.1.1 - Router redirect + β€’ 146.112.61.104/105 - OpenDNS sinkholes + β€’ 198.105.232.6/7 - Quad9 sinkholes + β€’ 185.228.168.10 - CleanBrowsing sinkholes +"# + .to_string() +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs new file mode 100644 index 0000000..d58c89b --- /dev/null +++ b/src/dns/mod.rs @@ -0,0 +1,689 @@ +use crate::utils::{measure_time, NetworkError, Result, TestResult}; +use std::net::{IpAddr, SocketAddr}; +use std::str::FromStr; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::time::timeout; +use trust_dns_client::rr::{Name, RData, RecordData, RecordType}; +use trust_dns_resolver::config::*; +use trust_dns_resolver::system_conf; +use trust_dns_resolver::TokioAsyncResolver; + +pub mod categories; +pub mod queries; + +pub use categories::*; +pub use queries::*; + +#[derive(Debug, Clone)] +pub enum ConnectivityStatus { + Reachable, + DnsOnlyNetworkBlocked, + PartiallyReachable, +} + +#[derive(Debug, Clone)] +pub struct DnsTest { + pub domain: String, + pub record_type: RecordType, + pub server: Option, + pub timeout: Duration, + pub use_tcp: bool, +} + +impl DnsTest { + pub fn new(domain: String, record_type: RecordType) -> Self { + Self { + domain, + record_type, + server: None, + timeout: Duration::from_secs(5), + use_tcp: false, + } + } + + pub fn with_server(mut self, server: SocketAddr) -> Self { + self.server = Some(server); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub fn with_tcp(mut self, use_tcp: bool) -> Self { + self.use_tcp = use_tcp; + self + } + + pub async fn run(&self) -> TestResult { + let protocol = if self.use_tcp { "TCP" } else { "UDP" }; + let server_info = self + .server + .map(|s| format!(" via {}", s)) + .unwrap_or_default(); + + let test_name = format!( + "DNS {:?} query for {} ({}{})", + self.record_type, self.domain, protocol, server_info + ); + + let (duration, result) = measure_time(|| async { + if let Some(server) = self.server { + self.query_specific_server(server).await + } else { + self.query_system_resolver().await + } + }) + .await; + + match result { + Ok(details) => TestResult::new(test_name).success(duration, details), + Err(error) => TestResult::new(test_name).failure(duration, error), + } + } + + pub async fn run_security_test(&self) -> TestResult { + let protocol = if self.use_tcp { "TCP" } else { "UDP" }; + let server_info = self + .server + .map(|s| format!(" via {}", s)) + .unwrap_or_default(); + + let test_name = format!( + "DNS {:?} query for {} ({}{})", + self.record_type, self.domain, protocol, server_info + ); + + let (duration, result) = measure_time(|| async { + if let Some(server) = self.server { + self.query_specific_server(server).await + } else { + self.query_system_resolver().await + } + }) + .await; + + match result { + Ok(details) => { + // Check if the resolved IPs are sinkholed + let ips = self.extract_ips_from_dns_details(&details); + let sinkhole_analysis = analyze_sinkhole_ips(&ips); + + match sinkhole_analysis { + SinkholeAnalysis::FullySinkholed(sinkhole_ips) => { + let sinkhole_list: Vec = + sinkhole_ips.iter().map(|ip| ip.to_string()).collect(); + TestResult::new(test_name).success( + duration, + format!( + "πŸ•³οΈ SINKHOLED (security success): Redirected to sinkhole IPs: {}", + sinkhole_list.join(", ") + ), + ) + } + SinkholeAnalysis::PartiallySinkholed { + sinkhole_ips, + legitimate_ips, + } => { + let sinkhole_list: Vec = + sinkhole_ips.iter().map(|ip| ip.to_string()).collect(); + let legit_list: Vec = + legitimate_ips.iter().map(|ip| ip.to_string()).collect(); + TestResult::new(test_name).success( + duration, + format!("⚠️ MIXED RESOLUTION: Sinkholed: {} | Real IPs: {} (partial security concern)", + sinkhole_list.join(", "), legit_list.join(", ")) + ) + } + SinkholeAnalysis::NotSinkholed(_) => TestResult::new(test_name).success( + duration, + format!("⚠️ RESOLVED (potential security concern): {}", details), + ), + } + } + Err(error) => { + // For security tests, DNS resolution failures are considered successes + match error { + NetworkError::DnsResolution(err_msg) => { + let blocking_explanation = analyze_dns_blocking(&err_msg); + TestResult::new(test_name).success( + duration, + format!("πŸ›‘οΈ BLOCKED (security success): {}", blocking_explanation), + ) + } + _ => TestResult::new(test_name).failure(duration, error), + } + } + } + } + + pub async fn run_filtering_test(&self) -> TestResult { + let protocol = if self.use_tcp { "TCP" } else { "UDP" }; + let server_info = self + .server + .map(|s| format!(" via {}", s)) + .unwrap_or_default(); + + let test_name = format!( + "DNS {:?} query for {} ({}{})", + self.record_type, self.domain, protocol, server_info + ); + + let (duration, result) = measure_time(|| async { + if let Some(server) = self.server { + self.query_specific_server(server).await + } else { + self.query_system_resolver().await + } + }) + .await; + + match result { + Ok(details) => { + // Check if the resolved IPs are sinkholed + let ips = self.extract_ips_from_dns_details(&details); + let sinkhole_analysis = analyze_sinkhole_ips(&ips); + + match sinkhole_analysis { + SinkholeAnalysis::FullySinkholed(sinkhole_ips) => { + let sinkhole_list: Vec = + sinkhole_ips.iter().map(|ip| ip.to_string()).collect(); + TestResult::new(test_name).success( + duration, + format!("πŸ•³οΈ SINKHOLED: Redirected to sinkhole IPs: {} (filtered via DNS redirect)", sinkhole_list.join(", ")) + ) + } + SinkholeAnalysis::PartiallySinkholed { + sinkhole_ips, + legitimate_ips, + } => { + let sinkhole_list: Vec = + sinkhole_ips.iter().map(|ip| ip.to_string()).collect(); + let legit_list: Vec = + legitimate_ips.iter().map(|ip| ip.to_string()).collect(); + TestResult::new(test_name).success( + duration, + format!("⚑ PARTIAL SINKHOLE: Sinkholed: {} | Real IPs: {} (partial filtering)", + sinkhole_list.join(", "), legit_list.join(", ")) + ) + } + SinkholeAnalysis::NotSinkholed(_) => TestResult::new(test_name) + .success(duration, format!("πŸ“‘ ACCESSIBLE: {}", details)), + } + } + Err(error) => { + // DNS resolution failed - for filtering tests, this is good (blocked) + match error { + NetworkError::DnsResolution(err_msg) => { + let blocking_explanation = analyze_dns_blocking(&err_msg); + TestResult::new(test_name) + .success(duration, format!("🚫 FILTERED: {}", blocking_explanation)) + } + _ => TestResult::new(test_name).failure(duration, error), + } + } + } + } + + pub async fn run_comprehensive_test(&self) -> TestResult { + let protocol = if self.use_tcp { "TCP" } else { "UDP" }; + let server_info = self + .server + .map(|s| format!(" via {}", s)) + .unwrap_or_default(); + + let test_name = format!( + "DNS {:?} query for {} ({}{})", + self.record_type, self.domain, protocol, server_info + ); + + let (dns_duration, dns_result) = measure_time(|| async { + if let Some(server) = self.server { + self.query_specific_server(server).await + } else { + self.query_system_resolver().await + } + }) + .await; + + match dns_result { + Ok(dns_details) => { + // DNS resolved, now check actual connectivity + let connectivity_result = + self.check_connectivity_to_resolved_ips(&dns_details).await; + let total_duration = dns_duration + Duration::from_millis(50); // Approximate connectivity check time + + match connectivity_result { + ConnectivityStatus::Reachable => { + TestResult::new(test_name).success( + total_duration, + format!("βœ… FULLY ACCESSIBLE: {} | Connectivity: Reachable", dns_details) + ) + } + ConnectivityStatus::DnsOnlyNetworkBlocked => { + TestResult::new(test_name).success( + total_duration, + format!("🌐 DNS RESOLVES, NETWORK BLOCKED: {} | Traffic blocked at ISP/router level", dns_details) + ) + } + ConnectivityStatus::PartiallyReachable => { + TestResult::new(test_name).success( + total_duration, + format!("⚑ PARTIALLY REACHABLE: {} | Some ports blocked", dns_details) + ) + } + } + } + Err(error) => match error { + NetworkError::DnsResolution(err_msg) => { + let blocking_explanation = analyze_dns_blocking(&err_msg); + TestResult::new(test_name).success( + dns_duration, + format!("πŸ›‘οΈ DNS BLOCKED: {}", blocking_explanation), + ) + } + _ => TestResult::new(test_name).failure(dns_duration, error), + }, + } + } + + async fn query_system_resolver(&self) -> Result { + // Try to use system DNS configuration first, fall back to default if that fails + let (config, opts) = match system_conf::read_system_conf() { + Ok((config, opts)) => (config, opts), + Err(_) => { + // Fallback to default config if system config cannot be read + eprintln!("Warning: Could not read system DNS config, using default"); + (ResolverConfig::default(), ResolverOpts::default()) + } + }; + + let resolver = TokioAsyncResolver::tokio(config, opts); + + let name = Name::from_str(&self.domain) + .map_err(|e| NetworkError::DnsResolution(format!("Invalid domain: {}", e)))?; + + let response = timeout(self.timeout, async { + match self.record_type { + RecordType::A => { + let lookup = resolver + .ipv4_lookup(name.clone()) + .await + .map_err(|e| format!("A lookup failed: {}", e))?; + let ips: Vec = lookup.iter().map(|ip| ip.to_string()).collect(); + Ok(format!("A records: {}", ips.join(", "))) + } + RecordType::AAAA => { + let lookup = resolver + .ipv6_lookup(name.clone()) + .await + .map_err(|e| format!("AAAA lookup failed: {}", e))?; + let ips: Vec = lookup.iter().map(|ip| ip.to_string()).collect(); + Ok(format!("AAAA records: {}", ips.join(", "))) + } + RecordType::MX => { + let lookup = resolver + .mx_lookup(name.clone()) + .await + .map_err(|e| format!("MX lookup failed: {}", e))?; + let records: Vec = lookup + .iter() + .map(|mx| format!("{} {}", mx.preference(), mx.exchange())) + .collect(); + Ok(format!("MX records: {}", records.join(", "))) + } + RecordType::TXT => { + let lookup = resolver + .txt_lookup(name.clone()) + .await + .map_err(|e| format!("TXT lookup failed: {}", e))?; + let records: Vec = lookup.iter().map(|txt| txt.to_string()).collect(); + Ok(format!("TXT records: {}", records.join(", "))) + } + RecordType::NS => { + let lookup = resolver + .ns_lookup(name.clone()) + .await + .map_err(|e| format!("NS lookup failed: {}", e))?; + let records: Vec = lookup.iter().map(|ns| ns.to_string()).collect(); + Ok(format!("NS records: {}", records.join(", "))) + } + RecordType::CNAME => { + let lookup = resolver + .lookup(name.clone(), self.record_type) + .await + .map_err(|e| format!("CNAME lookup failed: {}", e))?; + let records: Vec = lookup + .iter() + .filter_map(|record| { + if let RData::CNAME(cname) = record.clone().into_rdata() { + Some(cname.to_string()) + } else { + None + } + }) + .collect(); + Ok(format!("CNAME records: {}", records.join(", "))) + } + RecordType::SOA => { + let lookup = resolver + .soa_lookup(name.clone()) + .await + .map_err(|e| format!("SOA lookup failed: {}", e))?; + let records: Vec = lookup + .iter() + .map(|soa| { + format!( + "{} {} {} {} {} {} {}", + soa.mname(), + soa.rname(), + soa.serial(), + soa.refresh(), + soa.retry(), + soa.expire(), + soa.minimum() + ) + }) + .collect(); + Ok(format!("SOA records: {}", records.join(", "))) + } + RecordType::PTR => { + let lookup = resolver + .lookup(name.clone(), self.record_type) + .await + .map_err(|e| format!("PTR lookup failed: {}", e))?; + let records: Vec = lookup + .iter() + .filter_map(|record| { + if let RData::PTR(ptr) = record.clone().into_rdata() { + Some(ptr.to_string()) + } else { + None + } + }) + .collect(); + Ok(format!("PTR records: {}", records.join(", "))) + } + _ => { + let lookup = resolver + .lookup(name.clone(), self.record_type) + .await + .map_err(|e| format!("Lookup failed: {}", e))?; + let count = lookup.iter().count(); + Ok(format!("{:?} records: {} found", self.record_type, count)) + } + } + }) + .await + .map_err(|_| NetworkError::Timeout)? + .map_err(|_: String| NetworkError::DnsResolution("System resolver failed".to_string()))?; + + Ok(response) + } + + async fn query_specific_server(&self, server: SocketAddr) -> Result { + // Create a resolver configuration that uses the specific server + let mut config = ResolverConfig::new(); + config.add_name_server(trust_dns_resolver::config::NameServerConfig::new( + server, + trust_dns_resolver::config::Protocol::Udp, + )); + let opts = ResolverOpts::default(); + + let resolver = TokioAsyncResolver::tokio(config, opts); + + let name = Name::from_str(&self.domain) + .map_err(|e| NetworkError::DnsResolution(format!("Invalid domain: {}", e)))?; + + let response = timeout(self.timeout, async { + match self.record_type { + RecordType::A => { + let lookup = resolver + .ipv4_lookup(name.clone()) + .await + .map_err(|e| format!("A lookup failed: {}", e))?; + let ips: Vec = lookup.iter().map(|ip| ip.to_string()).collect(); + Ok(format!("A records: {}", ips.join(", "))) + } + RecordType::AAAA => { + let lookup = resolver + .ipv6_lookup(name.clone()) + .await + .map_err(|e| format!("AAAA lookup failed: {}", e))?; + let ips: Vec = lookup.iter().map(|ip| ip.to_string()).collect(); + Ok(format!("AAAA records: {}", ips.join(", "))) + } + _ => { + let lookup = resolver + .lookup(name.clone(), self.record_type) + .await + .map_err(|e| format!("Lookup failed: {}", e))?; + let count = lookup.iter().count(); + Ok(format!("{:?} records: {} found", self.record_type, count)) + } + } + }) + .await + .map_err(|_| NetworkError::Timeout)? + .map_err(|e| NetworkError::DnsResolution(e))?; + + Ok(format!("{} (via {})", response, server)) + } + + async fn check_connectivity_to_resolved_ips(&self, dns_details: &str) -> ConnectivityStatus { + // Extract IP addresses from DNS details string + let ips = self.extract_ips_from_dns_details(dns_details); + + if ips.is_empty() { + return ConnectivityStatus::DnsOnlyNetworkBlocked; + } + + let mut reachable_count = 0; + let test_ports = [80, 443, 8080]; // Common HTTP/HTTPS ports + + for ip in ips.iter().take(2) { + // Test first 2 IPs to avoid too many connections + for &port in &test_ports { + let addr = SocketAddr::new(*ip, port); + + if let Ok(_) = timeout(Duration::from_millis(1000), TcpStream::connect(addr)).await + { + reachable_count += 1; + break; // If any port works, IP is reachable + } + } + } + + if reachable_count > 0 { + if reachable_count == ips.len().min(2) { + ConnectivityStatus::Reachable + } else { + ConnectivityStatus::PartiallyReachable + } + } else { + ConnectivityStatus::DnsOnlyNetworkBlocked + } + } + + fn extract_ips_from_dns_details(&self, dns_details: &str) -> Vec { + let mut ips = Vec::new(); + + // Look for patterns like "A records: 1.2.3.4, 5.6.7.8" + if let Some(records_part) = dns_details.split("records: ").nth(1) { + for ip_str in records_part.split(", ") { + if let Ok(ip) = ip_str.trim().parse::() { + ips.push(ip); + } + } + } + + ips + } +} + +pub async fn test_common_dns_servers(domain: &str, record_type: RecordType) -> Vec { + let servers = [ + "8.8.8.8:53", // Google + "8.8.4.4:53", // Google + "1.1.1.1:53", // Cloudflare + "1.0.0.1:53", // Cloudflare + "9.9.9.9:53", // Quad9 + "208.67.222.222:53", // OpenDNS + "208.67.220.220:53", // OpenDNS + ]; + + let mut results = Vec::new(); + + for server_str in &servers { + if let Ok(server) = server_str.parse::() { + let test = DnsTest::new(domain.to_string(), record_type).with_server(server); + results.push(test.run().await); + } + } + + results +} + +pub async fn test_dns_over_tcp_udp( + domain: &str, + record_type: RecordType, + server: SocketAddr, +) -> Vec { + let mut results = Vec::new(); + + // UDP test + let udp_test = DnsTest::new(domain.to_string(), record_type) + .with_server(server) + .with_tcp(false); + results.push(udp_test.run().await); + + // TCP test + let tcp_test = DnsTest::new(domain.to_string(), record_type) + .with_server(server) + .with_tcp(true); + results.push(tcp_test.run().await); + + results +} + +/// Analyze DNS blocking to provide detailed explanation of what's happening +fn analyze_dns_blocking(error_msg: &str) -> String { + let error_lower = error_msg.to_lowercase(); + + if error_lower.contains("nxdomain") || error_lower.contains("name not found") { + "DNS server returned NXDOMAIN (domain doesn't exist or is blocked at DNS level)".to_string() + } else if error_lower.contains("servfail") || error_lower.contains("server failure") { + "DNS server returned SERVFAIL (possibly blocked by DNS filtering service)".to_string() + } else if error_lower.contains("refused") { + "DNS query refused (likely blocked by DNS server policy)".to_string() + } else if error_lower.contains("timeout") { + "DNS query timeout (domain may be sinkholed or filtered)".to_string() + } else if error_lower.contains("connection refused") { + "Connection refused to DNS server (network-level blocking)".to_string() + } else if error_lower.contains("system resolver failed") { + "System DNS resolver blocked the query (OS or network-level filtering)".to_string() + } else { + format!("DNS resolution failed ({})", error_msg) + } +} + +/// Analyze IP addresses to detect DNS sinkholing +fn analyze_sinkhole_ips(ips: &[IpAddr]) -> SinkholeAnalysis { + let mut sinkhole_ips = Vec::new(); + let mut legitimate_ips = Vec::new(); + + for &ip in ips { + if is_sinkhole_ip(ip) { + sinkhole_ips.push(ip); + } else { + legitimate_ips.push(ip); + } + } + + if !sinkhole_ips.is_empty() && legitimate_ips.is_empty() { + SinkholeAnalysis::FullySinkholed(sinkhole_ips) + } else if !sinkhole_ips.is_empty() { + SinkholeAnalysis::PartiallySinkholed { + sinkhole_ips, + legitimate_ips, + } + } else { + SinkholeAnalysis::NotSinkholed(legitimate_ips) + } +} + +/// Check if an IP address is a known sinkhole address +fn is_sinkhole_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ipv4) => { + let octets = ipv4.octets(); + + // Common sinkhole addresses + match octets { + [0, 0, 0, 0] => true, // 0.0.0.0 - most common + [127, 0, 0, 1] => true, // 127.0.0.1 - localhost redirect + [127, 0, 0, 2..=255] => true, // 127.x.x.x range + [10, 0, 0, 1] => true, // 10.0.0.1 - router sinkhole + [192, 168, 1, 1] => true, // 192.168.1.1 - router sinkhole + [192, 168, 0, 1] => true, // 192.168.0.1 - router sinkhole + [146, 112, 61, 104] => true, // OpenDNS sinkhole + [146, 112, 61, 105] => true, // OpenDNS sinkhole + [199, 85, 126, 10] => true, // Norton DNS sinkhole + [199, 85, 127, 10] => true, // Norton DNS sinkhole + [208, 69, 38, 170] => true, // OpenDNS phishing block page + [208, 69, 39, 170] => true, // OpenDNS phishing block page + [198, 105, 232, 6] => true, // Quad9 sinkhole + [198, 105, 232, 7] => true, // Quad9 sinkhole + [185, 228, 168, 10] => true, // CleanBrowsing sinkhole + [185, 228, 169, 11] => true, // CleanBrowsing sinkhole + _ => false, + } + } + IpAddr::V6(ipv6) => { + // IPv6 sinkhole addresses + let segments = ipv6.segments(); + match segments { + [0, 0, 0, 0, 0, 0, 0, 0] => true, // :: (IPv6 equivalent of 0.0.0.0) + [0, 0, 0, 0, 0, 0, 0, 1] => true, // ::1 (IPv6 localhost) + _ => false, + } + } + } +} + +#[derive(Debug, Clone)] +pub enum SinkholeAnalysis { + NotSinkholed(Vec), + FullySinkholed(Vec), + PartiallySinkholed { + sinkhole_ips: Vec, + legitimate_ips: Vec, + }, +} + +/// Debug function to show current DNS configuration +pub fn debug_dns_config() -> String { + match system_conf::read_system_conf() { + Ok((config, _opts)) => { + let mut debug_info = vec!["System DNS Configuration:".to_string()]; + + for name_server in config.name_servers() { + debug_info.push(format!( + " πŸ“‘ DNS Server: {} ({})", + name_server.socket_addr, + match name_server.protocol { + Protocol::Udp => "UDP", + Protocol::Tcp => "TCP", + _ => "Other", + } + )); + } + + debug_info.push(format!(" πŸ” Search domains: {:?}", config.search())); + debug_info.join("\n") + } + Err(e) => format!("❌ Could not read system DNS config: {}", e), + } +} diff --git a/src/dns/queries.rs b/src/dns/queries.rs new file mode 100644 index 0000000..69412a6 --- /dev/null +++ b/src/dns/queries.rs @@ -0,0 +1,147 @@ +use super::DnsTest; +use crate::utils::TestResult; +use trust_dns_client::rr::RecordType; + +pub async fn comprehensive_dns_test(domain: &str) -> Vec { + let mut results = Vec::new(); + + let record_types = [ + RecordType::A, + RecordType::AAAA, + RecordType::MX, + RecordType::NS, + RecordType::TXT, + RecordType::CNAME, + RecordType::SOA, + ]; + + for record_type in &record_types { + let test = DnsTest::new(domain.to_string(), *record_type); + results.push(test.run().await); + } + + results +} + +pub async fn test_large_dns_queries(domain: &str) -> Vec { + let mut results = Vec::new(); + + // Test with a domain that has large TXT records + let large_txt_domains = [ + "_dmarc.google.com", + "google.com", // Often has large TXT records for verification + "_domainkey.google.com", + ]; + + for test_domain in &large_txt_domains { + let test = DnsTest::new(test_domain.to_string(), RecordType::TXT); + results.push(test.run().await); + } + + // Test DNSSEC-related records if available + let dnssec_types = [ + RecordType::DS, + RecordType::RRSIG, + RecordType::DNSKEY, + RecordType::NSEC, + RecordType::NSEC3, + ]; + + for record_type in &dnssec_types { + let test = DnsTest::new(domain.to_string(), *record_type); + results.push(test.run().await); + } + + results +} + +pub async fn test_dns_amplification_domains() -> Vec { + // Test domains that might be used in DNS amplification attacks + // These are legitimate tests to check if the resolver handles them properly + let test_domains = [ + "isc.org", // Often has large responses + "ripe.net", // Registry with comprehensive records + "version.bind", // Special query + "hostname.bind", // Special query + ]; + + let mut results = Vec::new(); + + for domain in &test_domains { + let test = DnsTest::new(domain.to_string(), RecordType::TXT); + results.push(test.run().await); + } + + results +} + +pub async fn test_reverse_dns_lookups() -> Vec { + let mut results = Vec::new(); + + let test_ips = [ + "8.8.8.8", // Google DNS + "1.1.1.1", // Cloudflare DNS + "208.67.222.222", // OpenDNS + ]; + + for ip in &test_ips { + // Convert IP to reverse DNS format + let reverse_domain = if let Ok(addr) = ip.parse::() { + let octets = addr.octets(); + format!( + "{}.{}.{}.{}.in-addr.arpa", + octets[3], octets[2], octets[1], octets[0] + ) + } else { + continue; + }; + + let test = DnsTest::new(reverse_domain, RecordType::PTR); + results.push(test.run().await); + } + + results +} + +pub async fn test_international_domains() -> Vec { + let mut results = Vec::new(); + + // Test internationalized domain names + let international_domains = [ + "xn--n3h.com", // β˜ƒ.com (snowman emoji) + "xn--e1afmkfd.xn--p1ai", // ΠΏΡ€ΠΈΠΌΠ΅Ρ€.Ρ€Ρ„ (example.rf in Russian) + "xn--fsq.xn--0zwm56d", // ζ΅‹θ―•.ζ΅‹θ―• (test.test in Chinese) + ]; + + for domain in &international_domains { + let test = DnsTest::new(domain.to_string(), RecordType::A); + results.push(test.run().await); + } + + results +} + +pub async fn test_dns_query_sizes() -> Vec { + let mut results = Vec::new(); + + // Test queries that might produce different response sizes + let size_test_domains = [ + ("short.example", "Short domain name"), + ( + "very-long-subdomain-name-that-tests-dns-limits.example.com", + "Long domain name", + ), + ( + "a.b.c.d.e.f.g.h.i.j.k.l.m.n.o.p.q.r.s.t.u.v.w.x.y.z.example.com", + "Deep subdomain", + ), + ]; + + for (domain, description) in &size_test_domains { + let mut test_result = DnsTest::new(domain.to_string(), RecordType::A).run().await; + test_result.test_name = format!("DNS query size test: {}", description); + results.push(test_result); + } + + results +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b04788c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,11 @@ +pub mod cli; +pub mod dns; +pub mod mtu; +pub mod network; +pub mod utils; + +pub use cli::*; +pub use dns::*; +pub use mtu::*; +pub use network::*; +pub use utils::*; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..d0af1db --- /dev/null +++ b/src/main.rs @@ -0,0 +1,370 @@ +use clap::Parser; +use colored::*; +use env_logger; +use indicatif::{ProgressBar, ProgressStyle}; +use nettest::*; +use serde_json; +use std::time::Duration; + +#[tokio::main] +async fn main() { + let cli = cli::Cli::parse(); + + env_logger::Builder::from_default_env() + .filter_level(if cli.verbose { + log::LevelFilter::Info + } else { + log::LevelFilter::Warn + }) + .init(); + + let timeout = Duration::from_secs(cli.timeout); + + let results = match cli.command { + cli::Commands::Network { command } => handle_network_command(command, timeout).await, + cli::Commands::Dns { command } => handle_dns_command(command, timeout).await, + cli::Commands::Mtu { command } => handle_mtu_command(command, timeout).await, + cli::Commands::Full { target, ip_version } => { + handle_full_test(target, ip_version, timeout).await + } + }; + + if cli.json { + print_results_json(&results); + } else { + print_results_human(&results); + } + + let failed_tests = results.iter().filter(|r| !r.success).count(); + if failed_tests > 0 { + std::process::exit(1); + } +} + +async fn handle_network_command( + command: cli::NetworkCommands, + timeout: Duration, +) -> Vec { + match command { + cli::NetworkCommands::Tcp { + target, + port, + ip_version, + } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let test = network::NetworkTest::new( + target.clone(), + version, + network::NetworkProtocol::Tcp, + ) + .with_port(port) + .with_timeout(timeout); + results.push(test.run().await); + } + results + } + cli::NetworkCommands::Udp { + target, + port, + ip_version, + } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let test = network::NetworkTest::new( + target.clone(), + version, + network::NetworkProtocol::Udp, + ) + .with_port(port) + .with_timeout(timeout); + results.push(test.run().await); + } + results + } + cli::NetworkCommands::Ping { + target, + count, + ip_version, + } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let ping_results = network::ping_test(&target, version, count).await; + results.extend(ping_results); + } + results + } + cli::NetworkCommands::Ports { + target, + protocol, + ip_version, + } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + match protocol { + cli::ProtocolArg::Tcp => { + let tcp_results = network::test_tcp_common_ports(&target, version).await; + results.extend(tcp_results); + } + cli::ProtocolArg::Udp => { + let udp_results = network::test_udp_common_ports(&target, version).await; + results.extend(udp_results); + } + cli::ProtocolArg::Both => { + let tcp_results = network::test_tcp_common_ports(&target, version).await; + let udp_results = network::test_udp_common_ports(&target, version).await; + results.extend(tcp_results); + results.extend(udp_results); + } + } + } + results + } + } +} + +async fn handle_dns_command(command: cli::DnsCommands, timeout: Duration) -> Vec { + match command { + cli::DnsCommands::Query { + domain, + record_type, + server, + tcp, + } => { + let mut results = Vec::new(); + for rt in record_type.to_record_type() { + let mut test = dns::DnsTest::new(domain.clone(), rt) + .with_timeout(timeout) + .with_tcp(tcp); + + if let Some(server_addr) = server { + test = test.with_server(server_addr); + } + + results.push(test.run().await); + } + results + } + cli::DnsCommands::Servers { + domain, + record_type, + } => { + let mut results = Vec::new(); + for rt in record_type.to_record_type() { + let server_results = dns::test_common_dns_servers(&domain, rt).await; + results.extend(server_results); + } + results + } + cli::DnsCommands::Categories { + category, + record_type, + } => { + let mut results = Vec::new(); + let categories = category + .map(|c| c.to_categories()) + .unwrap_or_else(|| dns::categories::ALL_CATEGORIES.iter().collect()); + + for rt in record_type.to_record_type() { + for cat in &categories { + let cat_results = dns::categories::test_domain_category(cat, rt).await; + results.extend(cat_results); + } + } + results + } + cli::DnsCommands::Filtering => dns::categories::test_dns_filtering_effectiveness().await, + cli::DnsCommands::Debug => { + // Create a debug result showing DNS configuration + let debug_info = dns::debug_dns_config(); + vec![TestResult::new("DNS Configuration Debug".to_string()) + .success(Duration::from_millis(0), debug_info)] + } + cli::DnsCommands::Comprehensive { domain } => { + dns::queries::comprehensive_dns_test(&domain).await + } + cli::DnsCommands::Large { domain } => dns::queries::test_large_dns_queries(&domain).await, + } +} + +async fn handle_mtu_command(command: cli::MtuCommands, _timeout: Duration) -> Vec { + match command { + cli::MtuCommands::Discover { target, ip_version } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let result = mtu::full_mtu_discovery(&target, version).await; + results.push(result); + } + results + } + cli::MtuCommands::Common { target, ip_version } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let common_results = mtu::test_common_mtu_sizes(&target, version).await; + results.extend(common_results); + } + results + } + cli::MtuCommands::Range { + target, + min, + max, + ip_version, + } => { + let mut results = Vec::new(); + for version in ip_version.to_versions() { + let discovery = + mtu::MtuDiscovery::new(target.clone(), version).with_range(min, max); + results.push(discovery.discover().await); + } + results + } + } +} + +async fn handle_full_test( + target: String, + ip_version: cli::IpVersionArg, + timeout: Duration, +) -> Vec { + let versions = ip_version.to_versions(); + let total_tests = versions.len() * 10; // Rough estimate + + let pb = ProgressBar::new(total_tests as u64); + pb.set_style( + ProgressStyle::default_bar() + .template( + "{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}", + ) + .unwrap() + .progress_chars("β–ˆβ–‰β–Šβ–‹β–Œβ–β–Žβ– "), + ); + + let mut all_results = Vec::new(); + + for version in versions { + pb.set_message(format!("Testing {:?} connectivity...", version)); + + // Network tests + let tcp_test = + network::NetworkTest::new(target.clone(), version, network::NetworkProtocol::Tcp) + .with_port(80) + .with_timeout(timeout); + all_results.push(tcp_test.run().await); + pb.inc(1); + + let udp_test = + network::NetworkTest::new(target.clone(), version, network::NetworkProtocol::Udp) + .with_port(53) + .with_timeout(timeout); + all_results.push(udp_test.run().await); + pb.inc(1); + + // ICMP test + let ping_results = network::ping_test(&target, version, 3).await; + all_results.extend(ping_results); + pb.inc(3); + + // MTU discovery + pb.set_message(format!("Discovering MTU for {:?}...", version)); + let mtu_result = mtu::full_mtu_discovery(&target, version).await; + all_results.push(mtu_result); + pb.inc(1); + + // Common MTU sizes + let mtu_common = mtu::test_common_mtu_sizes(&target, version).await; + all_results.extend(mtu_common); + pb.inc(1); + } + + // DNS tests + pb.set_message("Testing DNS resolution..."); + let dns_results = dns::queries::comprehensive_dns_test(&target).await; + all_results.extend(dns_results); + pb.inc(1); + + // DNS servers test + let dns_servers = + dns::test_common_dns_servers(&target, trust_dns_client::rr::RecordType::A).await; + all_results.extend(dns_servers); + pb.inc(1); + + pb.finish_with_message("Testing complete!"); + all_results +} + +fn print_results_human(results: &[TestResult]) { + println!("\n{}", "=".repeat(80).blue()); + println!("{}", "Network Test Results".bold().blue()); + println!("{}", "=".repeat(80).blue()); + + let mut success_count = 0; + let mut failure_count = 0; + + for result in results { + let status = if result.success { + success_count += 1; + "PASS".green().bold() + } else { + failure_count += 1; + "FAIL".red().bold() + }; + + let duration_str = utils::format_duration(result.duration); + + println!("{} {} ({})", status, result.test_name, duration_str.cyan()); + + if result.success { + if !result.details.is_empty() { + println!(" βœ“ {}", result.details.green()); + } + } else { + if let Some(ref error) = result.error { + println!(" βœ— {}", error.to_string().red()); + } + } + println!(); + } + + println!("{}", "-".repeat(80).blue()); + println!( + "Summary: {} passed, {} failed, {} total", + success_count.to_string().green().bold(), + failure_count.to_string().red().bold(), + results.len().to_string().blue().bold() + ); + + if failure_count > 0 { + println!("{}", "Some tests failed!".red().bold()); + } else { + println!("{}", "All tests passed!".green().bold()); + } +} + +fn print_results_json(results: &[TestResult]) { + #[derive(serde::Serialize)] + struct JsonResult { + test_name: String, + success: bool, + duration_ms: u128, + details: Option, + error: Option, + } + + let json_results: Vec = results + .iter() + .map(|r| JsonResult { + test_name: r.test_name.clone(), + success: r.success, + duration_ms: r.duration.as_millis(), + details: if r.success && !r.details.is_empty() { + Some(r.details.clone()) + } else { + None + }, + error: r.error.as_ref().map(|e| e.to_string()), + }) + .collect(); + + println!("{}", serde_json::to_string_pretty(&json_results).unwrap()); +} diff --git a/src/mtu/mod.rs b/src/mtu/mod.rs new file mode 100644 index 0000000..ece0d7c --- /dev/null +++ b/src/mtu/mod.rs @@ -0,0 +1,132 @@ +use crate::network::IpVersion; +use crate::utils::{measure_time, NetworkError, Result, TestResult}; +use std::time::Duration; + +pub struct MtuDiscovery { + pub target: String, + pub ip_version: IpVersion, + pub timeout: Duration, + pub max_mtu: u16, + pub min_mtu: u16, +} + +impl Default for MtuDiscovery { + fn default() -> Self { + Self { + target: String::new(), + ip_version: IpVersion::V4, + timeout: Duration::from_secs(5), + max_mtu: 1500, + min_mtu: 68, + } + } +} + +impl MtuDiscovery { + pub fn new(target: String, ip_version: IpVersion) -> Self { + Self { + target, + ip_version, + ..Default::default() + } + } + + pub fn with_range(mut self, min_mtu: u16, max_mtu: u16) -> Self { + self.min_mtu = min_mtu; + self.max_mtu = max_mtu; + self + } + + pub async fn discover(&self) -> TestResult { + let test_name = format!("MTU discovery for {} ({:?})", self.target, self.ip_version); + + let (duration, result) = measure_time(|| async { self.binary_search_mtu().await }).await; + + match result { + Ok(mtu) => TestResult::new(test_name) + .success(duration, format!("Discovered MTU: {} bytes", mtu)), + Err(error) => TestResult::new(test_name).failure(duration, error), + } + } + + async fn binary_search_mtu(&self) -> Result { + let mut low = self.min_mtu; + let mut high = self.max_mtu; + let mut best_mtu = low; + + while low <= high { + let mid = (low + high) / 2; + + match self.test_mtu_size(mid).await { + Ok(_) => { + best_mtu = mid; + low = mid + 1; + } + Err(_) => { + high = mid - 1; + } + } + } + + Ok(best_mtu) + } + + async fn test_mtu_size(&self, mtu_size: u16) -> Result<()> { + // Use system ping with packet size for MTU testing + let ping_cmd = match self.ip_version { + IpVersion::V4 => "ping", + IpVersion::V6 => "ping6", + }; + + let payload_size = match self.ip_version { + IpVersion::V4 => mtu_size.saturating_sub(28), // IP header 20 + ICMP header 8 + IpVersion::V6 => mtu_size.saturating_sub(48), // IPv6 header 40 + ICMP header 8 + }; + + if payload_size < 8 { + return Err(NetworkError::InvalidMtu(mtu_size)); + } + + let output = tokio::process::Command::new(ping_cmd) + .args(&[ + "-c", + "1", + "-W", + "5000", + "-M", + "do", // Don't fragment + "-s", + &payload_size.to_string(), + &self.target, + ]) + .output() + .await + .map_err(|e| NetworkError::Io(e))?; + + if output.status.success() { + Ok(()) + } else { + Err(NetworkError::Other("MTU test failed".to_string())) + } + } +} + +pub async fn test_common_mtu_sizes(target: &str, ip_version: IpVersion) -> Vec { + let common_sizes = [68, 576, 1280, 1500, 4464, 9000]; + let mut results = Vec::new(); + + for &size in &common_sizes { + let discovery = MtuDiscovery::new(target.to_string(), ip_version).with_range(size, size); + + let mut result = discovery.discover().await; + result.test_name = format!("MTU test {} bytes for {} ({:?})", size, target, ip_version); + results.push(result); + } + + results +} + +pub async fn full_mtu_discovery(target: &str, ip_version: IpVersion) -> TestResult { + let discovery = MtuDiscovery::new(target.to_string(), ip_version); + discovery.discover().await +} diff --git a/src/network/icmp.rs b/src/network/icmp.rs new file mode 100644 index 0000000..42aabef --- /dev/null +++ b/src/network/icmp.rs @@ -0,0 +1,51 @@ +use super::{IpVersion, NetworkTest}; +use crate::utils::{NetworkError, Result, TestResult}; +use tokio::time::Duration; + +impl NetworkTest { + pub async fn test_icmp(&self) -> Result { + // Use system ping command for simplicity and compatibility + let target = &self.target; + + let ping_cmd = match self.ip_version { + IpVersion::V4 => "ping", + IpVersion::V6 => "ping6", + }; + + let output = tokio::process::Command::new(ping_cmd) + .args(&["-c", "1", "-W", "5000", target]) // 1 ping, 5 second timeout + .output() + .await + .map_err(|e| NetworkError::Io(e))?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if let Some(line) = stdout.lines().find(|line| line.contains("time=")) { + Ok(format!("ICMP ping successful: {}", line.trim())) + } else { + Ok("ICMP ping successful".to_string()) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(NetworkError::Other(format!("Ping failed: {}", stderr))) + } + } +} + +pub async fn ping_test(target: &str, ip_version: IpVersion, count: u32) -> Vec { + let mut results = Vec::new(); + + for i in 0..count { + let test = NetworkTest::new(target.to_string(), ip_version, super::NetworkProtocol::Icmp); + + let mut result = test.run().await; + result.test_name = format!("ICMP ping #{} to {} ({:?})", i + 1, target, ip_version); + results.push(result); + + if i < count - 1 { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + results +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..f11f338 --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,106 @@ +use crate::utils::{measure_time, NetworkError, Result, TestResult}; +use std::net::IpAddr; +use std::time::Duration; + +pub mod icmp; +pub mod tcp; +pub mod udp; + +pub use icmp::*; +pub use tcp::*; +pub use udp::*; + +#[derive(Debug, Clone, Copy)] +pub enum IpVersion { + V4, + V6, +} + +#[derive(Debug, Clone, Copy)] +pub enum NetworkProtocol { + Tcp, + Udp, + Icmp, +} + +#[derive(Debug, Clone)] +pub struct NetworkTest { + pub target: String, + pub ip_version: IpVersion, + pub protocol: NetworkProtocol, + pub port: Option, + pub timeout: Duration, +} + +impl NetworkTest { + pub fn new(target: String, ip_version: IpVersion, protocol: NetworkProtocol) -> Self { + Self { + target, + ip_version, + protocol, + port: None, + timeout: Duration::from_secs(5), + } + } + + pub fn with_port(mut self, port: u16) -> Self { + self.port = Some(port); + self + } + + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + pub async fn run(&self) -> TestResult { + let test_name = format!( + "{:?} test to {} {:?} {}", + self.protocol, + self.target, + self.ip_version, + self.port.map(|p| format!(":{}", p)).unwrap_or_default() + ); + + let (duration, result) = measure_time(|| async { + match self.protocol { + NetworkProtocol::Tcp => self.test_tcp().await, + NetworkProtocol::Udp => self.test_udp().await, + NetworkProtocol::Icmp => self.test_icmp().await, + } + }) + .await; + + match result { + Ok(details) => TestResult::new(test_name).success(duration, details), + Err(error) => TestResult::new(test_name).failure(duration, error), + } + } + + async fn resolve_target(&self) -> Result { + use trust_dns_resolver::config::*; + use trust_dns_resolver::TokioAsyncResolver; + + let resolver = + TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); + + let lookup = match self.ip_version { + IpVersion::V4 => { + let response = resolver + .ipv4_lookup(&self.target) + .await + .map_err(|e| NetworkError::DnsResolution(e.to_string()))?; + response.iter().next().map(|ip| IpAddr::V4(**ip)) + } + IpVersion::V6 => { + let response = resolver + .ipv6_lookup(&self.target) + .await + .map_err(|e| NetworkError::DnsResolution(e.to_string()))?; + response.iter().next().map(|ip| IpAddr::V6(**ip)) + } + }; + + lookup.ok_or_else(|| NetworkError::DnsResolution("No IP found".to_string())) + } +} diff --git a/src/network/tcp.rs b/src/network/tcp.rs new file mode 100644 index 0000000..43d79a3 --- /dev/null +++ b/src/network/tcp.rs @@ -0,0 +1,43 @@ +use super::{IpVersion, NetworkTest}; +use crate::utils::{NetworkError, Result, TestResult}; +use std::net::SocketAddr; +use tokio::net::TcpStream; +use tokio::time::timeout; + +impl NetworkTest { + pub async fn test_tcp(&self) -> Result { + let ip = self.resolve_target().await?; + let port = self.port.unwrap_or(80); + let addr = SocketAddr::new(ip, port); + + let stream = timeout(self.timeout, TcpStream::connect(addr)) + .await + .map_err(|_| NetworkError::Timeout)? + .map_err(|e| NetworkError::Io(e))?; + + let local_addr = stream.local_addr().map_err(NetworkError::Io)?; + let peer_addr = stream.peer_addr().map_err(NetworkError::Io)?; + + Ok(format!( + "TCP connection successful: {} -> {}", + local_addr, peer_addr + )) + } +} + +pub async fn test_tcp_ports(target: &str, ports: &[u16], ip_version: IpVersion) -> Vec { + let mut results = Vec::new(); + + for &port in ports { + let test = NetworkTest::new(target.to_string(), ip_version, super::NetworkProtocol::Tcp) + .with_port(port); + results.push(test.run().await); + } + + results +} + +pub async fn test_tcp_common_ports(target: &str, ip_version: IpVersion) -> Vec { + let common_ports = [22, 25, 53, 80, 110, 143, 443, 993, 995, 8080, 8443]; + test_tcp_ports(target, &common_ports, ip_version).await +} diff --git a/src/network/udp.rs b/src/network/udp.rs new file mode 100644 index 0000000..b749869 --- /dev/null +++ b/src/network/udp.rs @@ -0,0 +1,63 @@ +use super::{IpVersion, NetworkTest}; +use crate::utils::{NetworkError, Result, TestResult}; +use std::net::SocketAddr; +use tokio::net::UdpSocket; +use tokio::time::timeout; + +impl NetworkTest { + pub async fn test_udp(&self) -> Result { + let ip = self.resolve_target().await?; + let port = self.port.unwrap_or(53); + let addr = SocketAddr::new(ip, port); + + let bind_addr = match self.ip_version { + IpVersion::V4 => "0.0.0.0:0", + IpVersion::V6 => "[::]:0", + }; + + let socket = UdpSocket::bind(bind_addr).await.map_err(NetworkError::Io)?; + + socket.connect(addr).await.map_err(NetworkError::Io)?; + + let test_data = b"test"; + + let send_result = timeout(self.timeout, socket.send(test_data)) + .await + .map_err(|_| NetworkError::Timeout)? + .map_err(NetworkError::Io)?; + + let mut buf = [0; 1024]; + let recv_result = + timeout(std::time::Duration::from_millis(100), socket.recv(&mut buf)).await; + + let details = match recv_result { + Ok(Ok(bytes)) => format!( + "UDP test successful: sent {} bytes, received {} bytes to {}", + send_result, bytes, addr + ), + _ => format!( + "UDP test (send only): sent {} bytes to {} (no response expected for basic connectivity test)", + send_result, addr + ), + }; + + Ok(details) + } +} + +pub async fn test_udp_ports(target: &str, ports: &[u16], ip_version: IpVersion) -> Vec { + let mut results = Vec::new(); + + for &port in ports { + let test = NetworkTest::new(target.to_string(), ip_version, super::NetworkProtocol::Udp) + .with_port(port); + results.push(test.run().await); + } + + results +} + +pub async fn test_udp_common_ports(target: &str, ip_version: IpVersion) -> Vec { + let common_ports = [53, 67, 68, 123, 161, 162, 514, 1194, 5353]; + test_udp_ports(target, &common_ports, ip_version).await +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..11759b7 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,83 @@ +use std::time::{Duration, Instant}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum NetworkError { + #[error("Connection timeout")] + Timeout, + #[error("DNS resolution failed: {0}")] + DnsResolution(String), + #[error("Socket creation failed: {0}")] + SocketCreation(String), + #[error("Network unreachable")] + NetworkUnreachable, + #[error("Host unreachable")] + HostUnreachable, + #[error("Permission denied")] + PermissionDenied, + #[error("Invalid MTU size: {0}")] + InvalidMtu(u16), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Other error: {0}")] + Other(String), +} + +pub type Result = std::result::Result; + +#[must_use] +pub struct TestResult { + pub test_name: String, + pub success: bool, + pub duration: Duration, + pub details: String, + pub error: Option, +} + +impl TestResult { + pub const fn new(test_name: String) -> Self { + Self { + test_name, + success: false, + duration: Duration::ZERO, + details: String::new(), + error: None, + } + } + + pub fn success(mut self, duration: Duration, details: String) -> Self { + self.success = true; + self.duration = duration; + self.details = details; + self + } + + pub fn failure(mut self, duration: Duration, error: NetworkError) -> Self { + self.success = false; + self.duration = duration; + self.error = Some(error); + self + } +} + +pub fn format_duration(duration: Duration) -> String { + let ms = duration.as_millis(); + if ms < 1000 { + format!("{ms}ms") + } else { + format!("{:.2}s", duration.as_secs_f32()) + } +} + +pub async fn measure_time(f: F) -> (Duration, T) +where + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + let start = Instant::now(); + let result = f().await; + let duration = start.elapsed(); + (duration, result) +} + +mod tests; diff --git a/src/utils/tests.rs b/src/utils/tests.rs new file mode 100644 index 0000000..2ed7c30 --- /dev/null +++ b/src/utils/tests.rs @@ -0,0 +1,66 @@ +#[cfg(test)] +mod unit_tests { + use crate::utils::{format_duration, measure_time, NetworkError, TestResult}; + use std::time::Duration; + + #[test] + fn test_format_duration_milliseconds() { + let duration = Duration::from_millis(500); + assert_eq!(format_duration(duration), "500ms"); + } + + #[test] + fn test_format_duration_seconds() { + let duration = Duration::from_millis(1500); + assert_eq!(format_duration(duration), "1.50s"); + } + + #[test] + fn test_test_result_new() { + let result = TestResult::new("test_name".to_string()); + assert_eq!(result.test_name, "test_name"); + assert!(!result.success); + assert_eq!(result.duration, Duration::ZERO); + assert!(result.details.is_empty()); + assert!(result.error.is_none()); + } + + #[test] + fn test_test_result_success() { + let duration = Duration::from_millis(100); + let details = "Success details".to_string(); + let result = TestResult::new("test".to_string()).success(duration, details.clone()); + + assert!(result.success); + assert_eq!(result.duration, duration); + assert_eq!(result.details, details); + assert!(result.error.is_none()); + } + + #[test] + fn test_test_result_failure() { + let duration = Duration::from_millis(200); + let error = NetworkError::Timeout; + let result = TestResult::new("test".to_string()).failure(duration, error); + + assert!(!result.success); + assert_eq!(result.duration, duration); + assert!(result.details.is_empty()); + assert!(result.error.is_some()); + + assert!(matches!(result.error, Some(NetworkError::Timeout))); + } + + #[tokio::test] + async fn test_measure_time() { + let (duration, result) = measure_time(|| async { + tokio::time::sleep(Duration::from_millis(100)).await; + "test_result" + }) + .await; + + assert!(duration >= Duration::from_millis(90)); // Allow some margin + assert!(duration <= Duration::from_millis(200)); // Upper bound + assert_eq!(result, "test_result"); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..de12dd4 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,177 @@ +use nettest::*; +use std::time::Duration; + +#[tokio::test] +async fn test_network_tcp_connectivity() { + let test = network::NetworkTest::new( + "google.com".to_string(), + network::IpVersion::V4, + network::NetworkProtocol::Tcp, + ) + .with_port(80) + .with_timeout(Duration::from_secs(10)); + + let result = test.run().await; + assert!(result.success, "TCP test to google.com should succeed"); + assert!(result.duration > Duration::ZERO); +} + +#[tokio::test] +async fn test_network_udp_connectivity() { + let test = network::NetworkTest::new( + "8.8.8.8".to_string(), + network::IpVersion::V4, + network::NetworkProtocol::Udp, + ) + .with_port(53) + .with_timeout(Duration::from_secs(10)); + + let result = test.run().await; + assert!(result.success || !result.success); // UDP might not always respond, so we test both cases + assert!(result.duration > Duration::ZERO); +} + +#[tokio::test] +async fn test_dns_query() { + let test = dns::DnsTest::new( + "google.com".to_string(), + trust_dns_client::rr::RecordType::A, + ) + .with_timeout(Duration::from_secs(10)); + + let result = test.run().await; + assert!(result.success, "DNS A query for google.com should succeed"); + assert!(result.duration > Duration::ZERO); + assert!(!result.details.is_empty()); +} + +#[tokio::test] +async fn test_dns_servers() { + let results = + dns::test_common_dns_servers("google.com", trust_dns_client::rr::RecordType::A).await; + assert!(!results.is_empty()); + + let successful_results = results.iter().filter(|r| r.success).count(); + assert!( + successful_results > 0, + "At least one DNS server should respond" + ); +} + +#[tokio::test] +async fn test_mtu_discovery() { + let discovery = mtu::MtuDiscovery::new("google.com".to_string(), network::IpVersion::V4) + .with_range(68, 576); // Test smaller range for speed + + let result = discovery.discover().await; + assert!(result.duration > Duration::ZERO); +} + +#[tokio::test] +async fn test_comprehensive_dns() { + let results = dns::queries::comprehensive_dns_test("google.com").await; + assert!(!results.is_empty()); + + let successful_results = results.iter().filter(|r| r.success).count(); + assert!( + successful_results > 0, + "At least some DNS queries should succeed" + ); +} + +#[tokio::test] +async fn test_domain_categories() { + let results = dns::categories::test_domain_category( + &dns::categories::NORMAL_SITES, + trust_dns_client::rr::RecordType::A, + ) + .await; + + assert!(!results.is_empty()); + let successful_results = results.iter().filter(|r| r.success).count(); + assert!(successful_results > 0, "Normal sites should mostly resolve"); +} + +#[tokio::test] +async fn test_error_handling() { + let test = network::NetworkTest::new( + "nonexistent.invalid".to_string(), + network::IpVersion::V4, + network::NetworkProtocol::Tcp, + ) + .with_port(80) + .with_timeout(Duration::from_millis(100)); + + let result = test.run().await; + assert!(!result.success, "Test to nonexistent domain should fail"); + assert!(result.error.is_some()); +} + +#[tokio::test] +async fn test_timeout_handling() { + let test = network::NetworkTest::new( + "192.0.2.1".to_string(), // Reserved test IP that shouldn't respond + network::IpVersion::V4, + network::NetworkProtocol::Tcp, + ) + .with_port(80) + .with_timeout(Duration::from_millis(100)); + + let result = test.run().await; + assert!(!result.success, "Test to non-responsive IP should timeout"); + assert!(result.duration <= Duration::from_millis(200)); // Allow some margin +} + +#[tokio::test] +async fn test_ipv6_support() { + let test = network::NetworkTest::new( + "google.com".to_string(), + network::IpVersion::V6, + network::NetworkProtocol::Tcp, + ) + .with_port(80) + .with_timeout(Duration::from_secs(10)); + + let result = test.run().await; + // IPv6 might not be available in all test environments, so we just check it doesn't panic + assert!(result.duration > Duration::ZERO); +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn test_format_duration() { + assert_eq!(format_duration(Duration::from_millis(500)), "500ms"); + assert_eq!(format_duration(Duration::from_secs(2)), "2.00s"); + } + + #[test] + fn test_test_result_creation() { + let result = TestResult::new("test".to_string()); + assert_eq!(result.test_name, "test"); + assert!(!result.success); + assert_eq!(result.duration, Duration::ZERO); + } + + #[test] + fn test_test_result_success() { + let result = TestResult::new("test".to_string()) + .success(Duration::from_millis(100), "details".to_string()); + + assert!(result.success); + assert_eq!(result.duration, Duration::from_millis(100)); + assert_eq!(result.details, "details"); + } + + #[test] + fn test_test_result_failure() { + let error = NetworkError::Timeout; + let result = TestResult::new("test".to_string()).failure(Duration::from_millis(100), error); + + assert!(!result.success); + assert_eq!(result.duration, Duration::from_millis(100)); + assert!(result.error.is_some()); + } +}