diff --git a/.claude/commands/fix-ci.md b/.claude/commands/fix-ci.md new file mode 100644 index 00000000..4b14b000 --- /dev/null +++ b/.claude/commands/fix-ci.md @@ -0,0 +1,49 @@ +--- +description: Check local tests are passing and GitHub CI status, wait if running, fix failures and push +argument-hint: [branch-name] +allowed-tools: mcp__github__*, Bash(git:*), Bash(gh:*), Read, Edit, Write, Grep, Glob +--- + +## Task + +Monitor and fix local + GitHub CI checks for the PR branch. + +## Context + +- Target branch: $ARGUMENTS (use current branch if not specified) + +## Instructions + +1. **Run Local Tests First** (faster feedback): + - Run tests locally + - If tests fail locally: + - Analyze the root cause + - Fix the issues in the code + - Re-run local tests to verify the fix + - Commit the fix with a clear message + - Push the changes + - If tests pass locally, proceed to step 2 + +2. **Check CI Status**: Use the GitHub MCP tools + +3. **If CI is still running**: + - Wait 30-60 seconds + - Check again + - Repeat until all checks complete + +4. **If CI checks pass**: Report success and stop. + +5. **If CI checks fail**: + - Identify which checks failed + - Fetch the failure logs/details + - Analyze the root cause + - Fix the issues in the code + - Commit the fix with a clear message + - Push the changes + - Go back to step 1 to verify the fix (run local tests first) + +## Important + +- Always explain what failed and why before attempting fixes +- Make minimal, targeted fixes - don't refactor unrelated code +- If you cannot determine the fix, ask for help instead of guessing diff --git a/.claude/rewrite_in_rust_plan.md b/.claude/rewrite_in_rust_plan.md new file mode 100644 index 00000000..7f99935a --- /dev/null +++ b/.claude/rewrite_in_rust_plan.md @@ -0,0 +1,543 @@ +# Datadog Traceroute - Rust Rewrite Plan + +## Overview + +Rewrite the Go-based datadog-traceroute repository in Rust with full feature parity: +- **Protocols**: TCP (SYN, SACK), UDP, ICMP +- **Platforms**: Linux, macOS, Windows +- **Interfaces**: CLI + HTTP Server +- **Runtime**: Tokio async +- **Windows Driver**: FFI bindings to existing Datadog driver + +### Key Requirements +1. **API Compatibility**: Preserve existing CLI flags and HTTP server API exactly as-is +2. **E2E Testing**: Comprehensive E2E tests for all protocols on all platforms in GitHub CI +3. **Completion Criteria**: Implementation is ONLY complete when: + - All unit tests pass locally + - All E2E tests pass locally + - All GitHub CI checks pass (verify using GitHub MCP `gh pr checks` or `gh run view`) + +--- + +## 1. Cargo Workspace Structure + +``` +datadog-traceroute-rs/ +├── Cargo.toml # Workspace root +├── crates/ +│ ├── traceroute-core/ # Core types, traits, errors +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── error.rs # thiserror-based errors +│ │ ├── types.rs # ProbeResponse, TracerouteParams +│ │ ├── traits.rs # TracerouteDriver trait +│ │ ├── result.rs # Results, TracerouteHop (serde) +│ │ └── execution/ +│ │ ├── mod.rs +│ │ ├── parallel.rs # Parallel traceroute with tokio +│ │ └── serial.rs # Serial traceroute +│ │ +│ ├── traceroute-packets/ # Packet I/O abstraction +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── source.rs # Source trait +│ │ ├── sink.rs # Sink trait +│ │ ├── parser.rs # FrameParser (etherparse) +│ │ ├── filters.rs # BPF filters +│ │ └── platform/ +│ │ ├── mod.rs +│ │ ├── linux.rs # AF_PACKET source +│ │ ├── darwin.rs # BPF device source +│ │ └── windows.rs # Driver + raw socket +│ │ +│ ├── traceroute-tcp/ # TCP SYN traceroute +│ ├── traceroute-udp/ # UDP traceroute +│ ├── traceroute-icmp/ # ICMP traceroute +│ ├── traceroute-sack/ # TCP SACK traceroute +│ ├── traceroute-server/ # HTTP REST API (axum) +│ ├── traceroute-cli/ # CLI binary (clap) +│ └── datadog-driver-sys/ # Windows driver FFI bindings +│ +├── tests/ # Integration tests +└── benches/ # Benchmarks +``` + +--- + +## 2. Key Dependencies + +| Go Dependency | Rust Equivalent | +|--------------|-----------------| +| `gopacket` | `pnet` (construction) + `etherparse` (parsing) | +| `cobra` | `clap` (derive) | +| `golang.org/x/net` | `socket2` + `libc` | +| `vishvananda/netlink` | `rtnetlink` | +| `go-cache` | `moka` (async TTL cache) | +| `backoff/v5` | `backoff` | +| `DataDog agent driver` | Custom FFI in `datadog-driver-sys` | + +### Workspace Dependencies (Cargo.toml) + +```toml +[workspace.dependencies] +tokio = { version = "1.40", features = ["full"] } +pnet = "0.35" +etherparse = "0.16" +socket2 = "0.5" +clap = { version = "4.5", features = ["derive"] } +axum = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "2.0" +tracing = "0.1" +moka = { version = "0.12", features = ["future"] } +hickory-resolver = "0.25" +uuid = { version = "1.11", features = ["v4"] } +``` + +--- + +## 3. Core Traits + +### TracerouteDriver (from `common/traceroute_types.go`) + +```rust +#[async_trait] +pub trait TracerouteDriver: Send + Sync { + fn get_driver_info(&self) -> TracerouteDriverInfo; + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError>; + async fn receive_probe(&mut self, timeout: Duration) -> Result, TracerouteError>; + async fn close(&mut self) -> Result<(), TracerouteError>; +} +``` + +### Source/Sink (from `packets/packet_source.go`, `packets/packet_sink.go`) + +```rust +#[async_trait] +pub trait Source: Send + Sync { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<()>; + async fn read(&mut self, buf: &mut [u8]) -> Result; + fn set_packet_filter(&mut self, spec: PacketFilterSpec) -> Result<()>; + async fn close(&mut self) -> Result<()>; +} + +#[async_trait] +pub trait Sink: Send + Sync { + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<()>; + async fn close(&mut self) -> Result<()>; +} +``` + +--- + +## 4. Implementation Phases + +### Phase 1: Foundation +**Goal**: Core infrastructure + UDP traceroute on Linux + +1. Set up workspace with all crate stubs +2. Implement `traceroute-core`: + - Error types (`TracerouteError` with `thiserror`) + - Core types (`ProbeResponse`, `TracerouteParams`, `TracerouteConfig`) + - `TracerouteDriver` trait + - Result types with serde serialization +3. Implement `traceroute-packets` (Linux): + - `Source`/`Sink` traits + - `AfPacketSource` using `libc::AF_PACKET` + - `FrameParser` using `etherparse` + - cBPF filter application +4. Implement `traceroute-udp`: + - `UdpDriver` implementing `TracerouteDriver` + - UDP packet construction with `pnet` + - Serial execution + +**Key files to reference**: +- `common/traceroute_types.go` - Core types +- `udp/udp_driver.go` - UDP driver pattern +- `packets/afpacket_source_linux.go` - AF_PACKET implementation + +### Phase 2: TCP + Parallel Execution +**Goal**: TCP SYN traceroute with parallel mode + +1. Implement `traceroute-tcp`: + - TCP SYN packet construction + - Paris traceroute mode (fixed packet ID) + - `TcpDriver` with SYNACK/RST detection +2. Implement parallel execution: + - `traceroute_parallel()` using `tokio::select!` + - Sender/receiver tasks with channel coordination +3. TCP-specific BPF filters + +**Key files to reference**: +- `tcp/tcp_driver.go` - TCP driver +- `tcp/tcpv4.go` - Packet construction +- `common/traceroute_parallel.go` - Parallel algorithm + +### Phase 3: ICMP + SACK +**Goal**: Complete protocol coverage + +1. Implement `traceroute-icmp`: + - ICMP Echo request/reply + - ICMPv4 and ICMPv6 support +2. Implement `traceroute-sack`: + - TCP handshake sequence + - SACK option parsing + - Fallback to SYN when unsupported + +**Key files to reference**: +- `icmp/icmp_driver.go` - ICMP driver +- `sack/sack_driver.go` - SACK implementation +- `sack/sack_packet.go` - SACK packet handling + +### Phase 4: macOS Support +**Goal**: Cross-platform for macOS + +1. Implement `BpfDevice`: + - `/dev/bpfN` device selection + - `BIOCIMMEDIATE`, `BIOCSETIF` ioctls + - DLT_NULL vs DLT_EN10MB handling +2. macOS raw socket sink +3. Loopback interface detection + +**Key files to reference**: +- `packets/bpfdevice_darwin.go` - BPF device +- `packets/sourcesink_darwin.go` - macOS factories + +### Phase 5: Windows Support +**Goal**: Windows with driver option + +1. Implement raw socket mode (non-driver): + - `RawConn` using `windows` crate + - IP_HDRINCL socket option +2. Implement driver FFI (`datadog-driver-sys`): + - `SourceDriver` with IOCP + - `SinkDriver` for transmission + - Filter definition structures +3. Sequential socket fallback for TCP + +**Key files to reference**: +- `packets/driver_source_windows.go` - Driver source +- `packets/driver_sink_windows.go` - Driver sink +- `tcp/seqsocket_windows.go` - Socket fallback + +### Phase 6: CLI + HTTP Server (API Compatibility Required) +**Goal**: User interfaces with exact API compatibility + +1. Implement `traceroute-cli` (must match existing Go CLI exactly): + - **Preserve all existing flags**: + - `-P, --proto` (udp, tcp, icmp) + - `-p, --port` (default: 33434) + - `-q, --traceroute-queries` (default: 3) + - `-m, --max-ttl` (default: 30) + - `-v, --verbose` + - `--tcp-method` (syn, sack, prefer_sack) + - `--ipv6` + - `--timeout` + - `--reverse-dns` + - `--source-public-ip` + - `-Q, --e2e-queries` (default: 50) + - `--windows-driver` + - `--skip-private-hops` + - JSON output format must be identical + - Logging with `tracing` +2. Implement `traceroute-server` (must match existing API exactly): + - Axum HTTP server on port 3765 + - `GET /traceroute?target=&protocol=&port=` - same query params + - Same JSON response format + - `-a, --addr` and `-l, --log-level` flags +3. Supporting features: + - Public IP fetching (same 5 fallback URLs) + - Reverse DNS with moka cache + - TTL cache layer + +**Key files to reference**: +- `cmd/root.go` - CLI flags (MUST match exactly) +- `server/server.go` - HTTP endpoints (MUST match exactly) +- `publicip/fetcher.go` - Public IP fetching + +### Phase 7: E2E Testing + Polish +**Goal**: Comprehensive E2E tests for all protocols on all platforms + +1. **Unit tests** with `mockall` for traits +2. **E2E tests for all protocols** (port and expand from Go `e2etests/`): + - UDP traceroute tests (localhost + public targets) + - TCP SYN traceroute tests + - TCP SACK traceroute tests + - ICMP traceroute tests + - Server API tests +3. **Platform coverage** - all E2E tests run on: + - Linux (ubuntu-latest) + - macOS (macos-latest) + - Windows (windows-latest) +4. Property-based tests with `proptest` +5. Documentation (rustdoc) +6. CI/CD with cross-compilation + +**Key files to reference**: +- `e2etests/cli_test.go` - CLI E2E tests to port +- `e2etests/server_test.go` - Server E2E tests to port +- `.github/workflows/test.yml` - Existing CI configuration + +### Phase 8: Verification (REQUIRED FOR COMPLETION) +**Goal**: Verify all tests pass locally and on GitHub CI + +1. **Local verification**: + ```bash + cargo test --all-features # All unit tests + cargo test --test e2e_cli # CLI E2E tests + cargo test --test e2e_server # Server E2E tests + ``` + +2. **GitHub CI verification** (using GitHub MCP): + ```bash + # Push branch and create PR + git push -u origin + gh pr create --title "Rust rewrite" --body "..." + + # Wait for CI and verify all checks pass + gh pr checks --watch + + # Or view specific workflow run + gh run view + ``` + +3. **Completion checklist**: + - [ ] `cargo test` passes locally on Linux/macOS/Windows + - [ ] `cargo clippy -- -D warnings` has no warnings + - [ ] `cargo fmt --check` passes + - [ ] GitHub CI unit-tests job: ✅ PASSED (all 3 platforms) + - [ ] GitHub CI e2e-tests job: ✅ PASSED (all protocols, all platforms) + - [ ] GitHub CI e2e-fake-network job: ✅ PASSED (Linux) + +--- + +## 5. Error Handling + +Use `thiserror` for structured errors: + +```rust +#[derive(Error, Debug)] +pub enum TracerouteError { + #[error("Socket creation failed: {0}")] + SocketCreation(#[source] std::io::Error), + + #[error("Read timeout exceeded")] + ReadTimeout, + + #[error("Packet mismatch")] + PacketMismatch, + + #[error("Driver not available")] + DriverNotAvailable, + // ... +} + +impl TracerouteError { + pub fn is_retryable(&self) -> bool { + matches!(self, Self::ReadTimeout | Self::PacketMismatch) + } +} +``` + +--- + +## 6. Platform Abstraction + +Use conditional compilation: + +```rust +// traceroute-packets/src/platform/mod.rs +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod darwin; +#[cfg(target_os = "windows")] +mod windows; + +pub async fn new_source_sink(addr: IpAddr, use_driver: bool) -> Result { + #[cfg(target_os = "linux")] + return linux::new_source_sink(addr).await; + + #[cfg(target_os = "macos")] + return darwin::new_source_sink(addr).await; + + #[cfg(target_os = "windows")] + return windows::new_source_sink(addr, use_driver).await; +} +``` + +--- + +## 7. CI/CD + +### GitHub Actions - Test Workflow + +```yaml +name: Test +on: [push, pull_request] + +jobs: + unit-tests: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --all-features + - run: cargo clippy -- -D warnings + - run: cargo fmt --check + + e2e-tests: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + protocol: [udp, tcp, icmp] + include: + - os: ubuntu-latest + protocol: sack + - os: macos-latest + protocol: sack + - os: windows-latest + protocol: sack + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: Build + run: cargo build --release + - name: E2E - Localhost (${{ matrix.protocol }}) + run: cargo test --test e2e_cli -- --protocol ${{ matrix.protocol }} localhost + - name: E2E - Public Target (${{ matrix.protocol }}) + run: cargo test --test e2e_cli -- --protocol ${{ matrix.protocol }} public + - name: E2E - Server API + if: matrix.protocol == 'udp' + run: cargo test --test e2e_server + + e2e-fake-network: + runs-on: ubuntu-latest # Linux only (uses network namespaces) + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: E2E - Fake Network (all protocols) + run: sudo cargo test --test e2e_fake_network +``` + +### GitHub Actions - Release Workflow + +```yaml +name: Release +on: + push: + tags: ['v*'] + +jobs: + release: + strategy: + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - run: cargo build --release --target ${{ matrix.target }} + - uses: actions/upload-artifact@v4 + with: + name: datadog-traceroute-${{ matrix.target }} + path: target/${{ matrix.target }}/release/datadog-traceroute* +``` + +--- + +## 8. Critical Go Files to Reference + +| Go File | Purpose | Rust Equivalent | +|---------|---------|-----------------| +| `common/traceroute_types.go` | Core interfaces | `traceroute-core/src/traits.rs` | +| `common/traceroute_parallel.go` | Parallel algorithm | `traceroute-core/src/execution/parallel.rs` | +| `common/traceroute_serial.go` | Serial algorithm | `traceroute-core/src/execution/serial.rs` | +| `packets/frame_parser.go` | Packet parsing | `traceroute-packets/src/parser.rs` | +| `packets/afpacket_source_linux.go` | Linux AF_PACKET | `traceroute-packets/src/platform/linux.rs` | +| `packets/bpfdevice_darwin.go` | macOS BPF | `traceroute-packets/src/platform/darwin.rs` | +| `packets/driver_source_windows.go` | Windows driver | `traceroute-packets/src/platform/windows.rs` | +| `tcp/tcp_driver.go` | TCP implementation | `traceroute-tcp/src/driver.rs` | +| `udp/udp_driver.go` | UDP implementation | `traceroute-udp/src/driver.rs` | +| `icmp/icmp_driver.go` | ICMP implementation | `traceroute-icmp/src/driver.rs` | +| `sack/sack_driver.go` | SACK implementation | `traceroute-sack/src/driver.rs` | +| `result/result.go` | Result structures (JSON format) | `traceroute-core/src/result.rs` | +| `cmd/root.go` | CLI flags (MUST MATCH) | `traceroute-cli/src/main.rs` | +| `server/server.go` | HTTP API (MUST MATCH) | `traceroute-server/src/lib.rs` | +| `e2etests/cli_test.go` | CLI E2E tests | `tests/e2e_cli.rs` | +| `e2etests/server_test.go` | Server E2E tests | `tests/e2e_server.rs` | +| `.github/workflows/test.yml` | CI configuration | `.github/workflows/test.yml` | + +--- + +## 9. API Compatibility Testing + +To ensure the Rust implementation is a drop-in replacement: + +1. **CLI Flag Compatibility Tests**: + - Parse same arguments as Go version + - `--help` output should match structure + - Invalid flag handling should match + +2. **JSON Output Compatibility Tests**: + - Compare JSON output structure field-by-field + - Use JSON schema validation + - Test all result fields: `protocol`, `source`, `destination`, `traceroute`, `e2e_probe` + +3. **Server API Compatibility Tests**: + - Same query parameters accepted + - Same HTTP status codes + - Same JSON response format + - Same error responses + +4. **Regression Testing**: + - Run both Go and Rust versions against same targets + - Compare JSON outputs (ignoring timing-sensitive fields like RTT values) + - Ensure same hop detection behavior + +--- + +## 10. Notes + +- Start implementation in a new `rust/` directory or separate branch +- Maintain Go version during transition for comparison testing +- Use `cargo-nextest` for faster test execution +- Consider `criterion` for performance benchmarks comparing to Go +- Run Go and Rust E2E tests in parallel during transition to catch regressions + +## 11. Definition of Done + +**The Rust rewrite is NOT complete until ALL of the following are verified:** + +1. ✅ All unit tests pass locally (`cargo test --all-features`) +2. ✅ All E2E tests pass locally (all protocols) +3. ✅ Code passes linting (`cargo clippy -- -D warnings`) +4. ✅ Code is formatted (`cargo fmt --check`) +5. ✅ GitHub CI checks ALL PASS - verified via: + ```bash + gh pr checks + # Must show ALL checks as ✅ passing + ``` +6. ✅ CI passes on ALL platforms: Linux, macOS, Windows +7. ✅ CI passes for ALL protocols: UDP, TCP, ICMP, SACK + +**Do not consider the implementation complete until `gh pr checks` shows all green checkmarks.** diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..0fe118ee --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(gh pr list:*)", + "Bash(gh pr checks:*)", + "Bash(gh run view:*)", + "Bash(gofmt:*)", + "Bash(go test:*)", + "Bash(git add:*)", + "Bash(git commit -m \"$\\(cat <<''EOF''\nFix CI failures from test breakages\n\n- Remove division by zero in traceroute_serial.go:58\n- Add missing g.Wait\\(\\) call in traceroute_parallel.go\n- Fix swapped DstIP and DstPort in tcp/parser.go\n\nThese were intentional breakages \\(break 1, 2, 3 commits\\) for testing.\nAll unit tests now passing locally.\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Sonnet 4.5 \nEOF\n\\)\")", + "Bash(git push)", + "Bash(make build:*)", + "Bash(make test:*)", + "Bash(./datadog-traceroute:*)", + "Bash(xargs gofmt:*)", + "Bash(git commit:*)", + "Bash(go build:*)", + "Bash(cargo check:*)", + "Bash(export PATH=\"$HOME/.cargo/bin:$PATH\")", + "Bash(export PATH=\"$HOME/.cargo/bin:/usr/bin:/bin:$PATH\")", + "Bash(cargo test:*)", + "Bash(cargo doc:*)", + "Bash(grep:*)", + "Bash(cargo fmt:*)", + "Bash(~/.cargo/bin/cargo fmt)", + "Bash(~/.cargo/bin/cargo clippy --all-features -- -D warnings)", + "Bash(cargo clippy:*)", + "Bash(find:*)", + "Bash(gh run list:*)", + "Bash(cargo build:*)", + "Bash(~/.cargo/bin/cargo build:*)", + "Bash(~/.cargo/bin/cargo test)", + "Bash(~/.cargo/bin/cargo fmt:*)", + "Bash(sudo ~/.cargo/bin/cargo test:*)", + "Bash(gh api:*)", + "Bash(~/.cargo/bin/cargo check:*)" + ] + } +} diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml new file mode 100644 index 00000000..ca158963 --- /dev/null +++ b/.github/workflows/rust-test.yml @@ -0,0 +1,118 @@ +name: rust-test + +on: + push: + branches: + - "main" + - "mq-working-branch-*" + - "alex/rewrite-in-rust" + tags: + - "v*" + pull_request: + paths: + - "rust/**" + workflow_dispatch: + +jobs: + rust_unit_tests: + name: "Rust Unit Tests" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust -> target" + + - name: Build + run: cargo build --all-features + + - name: Run Unit Tests + run: cargo test --all-features -- --skip test_localhost --skip test_public --nocapture + + - name: Check formatting + run: cargo fmt -- --check + + - name: Run Clippy + run: cargo clippy --all-features -- -D warnings + + rust_e2e_localhost_tests: + name: "Rust E2E Localhost Tests" + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + working-directory: rust + env: + EXECUTABLE: ${{ matrix.os == 'windows-latest' && 'target/release/datadog-traceroute.exe' || 'target/release/datadog-traceroute' }} + RUST_LOG: debug + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust -> target" + + - name: Build Release + run: cargo build --release + + - name: Run Localhost E2E Tests + timeout-minutes: 10 + run: cargo test --release -- --ignored test_localhost --test-threads=1 --nocapture + + rust_e2e_public_target_tests: + name: "Rust E2E Public Target Tests" + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + working-directory: rust + env: + EXECUTABLE: ${{ matrix.os == 'windows-latest' && 'target/release/datadog-traceroute.exe' || 'target/release/datadog-traceroute' }} + RUST_LOG: debug + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "rust -> target" + + - name: Build Release + run: cargo build --release + + - name: Run Public Target E2E Tests + timeout-minutes: 10 + run: cargo test --release -- --ignored test_public --test-threads=1 --nocapture diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..c5cc1bed --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,95 @@ +[workspace] +resolver = "2" +members = [ + "crates/traceroute-core", + "crates/traceroute-packets", + "crates/traceroute-tcp", + "crates/traceroute-udp", + "crates/traceroute-icmp", + "crates/traceroute-sack", + "crates/traceroute-server", + "crates/traceroute-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/DataDog/datadog-traceroute" +rust-version = "1.75" + +[workspace.dependencies] +# Async runtime +tokio = { version = "1.40", features = ["full"] } +async-trait = "0.1" + +# Networking +pnet = "0.35" +pnet_packet = "0.35" +etherparse = "0.16" +socket2 = { version = "0.5", features = ["all"] } + +# CLI +clap = { version = "4.5", features = ["derive"] } + +# HTTP server +axum = "0.7" +tower = "0.5" +tower-http = { version = "0.6", features = ["trace"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Error handling +thiserror = "2.0" +anyhow = "1.0" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Caching +moka = { version = "0.12", features = ["future"] } + +# DNS +hickory-resolver = "0.24" + +# Utilities +uuid = { version = "1.11", features = ["v4"] } +rand = "0.8" +bytes = "1.9" +parking_lot = "0.12" +futures = "0.3" +pin-project-lite = "0.2" + +# HTTP client +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + +# Testing +mockall = "0.13" +proptest = "1.5" +regex = "1.10" + +# Internal crates +traceroute-core = { path = "crates/traceroute-core" } +traceroute-packets = { path = "crates/traceroute-packets" } +traceroute-tcp = { path = "crates/traceroute-tcp" } +traceroute-udp = { path = "crates/traceroute-udp" } +traceroute-icmp = { path = "crates/traceroute-icmp" } +traceroute-sack = { path = "crates/traceroute-sack" } +traceroute-server = { path = "crates/traceroute-server" } + +# Platform-specific dependencies (used by individual crates) +libc = "0.2" +windows-sys = { version = "0.59", features = [ + "Win32_Networking_WinSock", + "Win32_Foundation", + "Win32_System_IO", + "Win32_System_Threading", +]} + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/rust/crates/traceroute-cli/Cargo.toml b/rust/crates/traceroute-cli/Cargo.toml new file mode 100644 index 00000000..b364d704 --- /dev/null +++ b/rust/crates/traceroute-cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "traceroute-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "CLI for datadog-traceroute" + +[[bin]] +name = "datadog-traceroute" +path = "src/main.rs" + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +traceroute-udp = { workspace = true } +traceroute-tcp = { workspace = true } +traceroute-icmp = { workspace = true } +traceroute-sack = { workspace = true } +tokio = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +moka = { workspace = true } +hickory-resolver = { workspace = true } +reqwest = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/rust/crates/traceroute-cli/src/main.rs b/rust/crates/traceroute-cli/src/main.rs new file mode 100644 index 00000000..2d514ce6 --- /dev/null +++ b/rust/crates/traceroute-cli/src/main.rs @@ -0,0 +1,157 @@ +//! CLI for datadog-traceroute. + +mod runner; + +use clap::Parser; +use std::process::ExitCode; +use std::time::Duration; +use traceroute_core::{Protocol, TcpMethod, TracerouteConfig, TracerouteParams}; + +/// Datadog Traceroute - Network path analysis tool. +#[derive(Parser, Debug)] +#[command(name = "datadog-traceroute")] +#[command(version)] +#[command(about = "Datadog Traceroute - Network path analysis tool")] +pub struct Args { + /// Target hostname or IP address. + #[arg(required = true)] + pub target: String, + + /// Protocol to use. + #[arg(short = 'P', long, default_value = "udp")] + pub proto: String, + + /// Destination port. + #[arg(short, long, default_value = "33434")] + pub port: u16, + + /// Number of traceroute queries. + #[arg(short = 'q', long = "traceroute-queries", default_value = "3")] + pub traceroute_queries: usize, + + /// Maximum TTL. + #[arg(short = 'm', long = "max-ttl", default_value = "30")] + pub max_ttl: u8, + + /// Enable verbose logging. + #[arg(short, long)] + pub verbose: bool, + + /// TCP method (syn, sack, prefer_sack). + #[arg(long = "tcp-method", default_value = "syn")] + pub tcp_method: String, + + /// Use IPv6. + #[arg(long)] + pub ipv6: bool, + + /// Timeout per probe in milliseconds. + #[arg(long, default_value = "3000")] + pub timeout: u64, + + /// Perform reverse DNS lookups. + #[arg(long = "reverse-dns")] + pub reverse_dns: bool, + + /// Collect source public IP. + #[arg(long = "source-public-ip")] + pub source_public_ip: bool, + + /// Number of end-to-end probes. + #[arg(short = 'Q', long = "e2e-queries", default_value = "50")] + pub e2e_queries: usize, + + /// Use Windows driver (Windows only). + #[arg(long = "windows-driver")] + pub windows_driver: bool, + + /// Skip private hops in output. + #[arg(long = "skip-private-hops")] + pub skip_private_hops: bool, +} + +impl Args { + /// Convert CLI args to TracerouteConfig. + fn to_config(&self) -> Result { + let protocol: Protocol = self + .proto + .parse() + .map_err(|e| format!("Invalid protocol: {}", e))?; + + let tcp_method: TcpMethod = self + .tcp_method + .parse() + .map_err(|e| format!("Invalid TCP method: {}", e))?; + + Ok(TracerouteConfig { + hostname: self.target.clone(), + port: self.port, + protocol, + params: TracerouteParams { + min_ttl: 1, + max_ttl: self.max_ttl, + timeout: Duration::from_millis(self.timeout), + poll_frequency: Duration::from_millis(100), + send_delay: Duration::from_millis(50), + }, + tcp_method, + want_v6: self.ipv6, + reverse_dns: self.reverse_dns, + collect_source_public_ip: self.source_public_ip, + traceroute_queries: self.traceroute_queries, + e2e_queries: self.e2e_queries, + use_windows_driver: self.windows_driver, + skip_private_hops: self.skip_private_hops, + }) + } +} + +#[tokio::main] +async fn main() -> ExitCode { + let args = Args::parse(); + + // Initialize logging to stderr to avoid corrupting JSON output + if args.verbose { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter("debug") + .init(); + } else { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter("info") + .init(); + } + + let config = match args.to_config() { + Ok(c) => c, + Err(e) => { + eprintln!("Error: {}", e); + return ExitCode::FAILURE; + } + }; + + tracing::info!( + target = %config.hostname, + protocol = %config.protocol, + port = config.port, + "Starting traceroute" + ); + + match runner::run_traceroute(config).await { + Ok(results) => match results.to_json() { + Ok(json) => { + println!("{}", json); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Failed to serialize results: {}", e); + ExitCode::FAILURE + } + }, + Err(e) => { + eprintln!("Traceroute failed: {}", e); + ExitCode::FAILURE + } + } +} diff --git a/rust/crates/traceroute-cli/src/runner.rs b/rust/crates/traceroute-cli/src/runner.rs new file mode 100644 index 00000000..ac458eea --- /dev/null +++ b/rust/crates/traceroute-cli/src/runner.rs @@ -0,0 +1,380 @@ +//! Traceroute runner that orchestrates the entire traceroute process. + +use hickory_resolver::TokioAsyncResolver; +use std::net::{IpAddr, SocketAddr}; +use traceroute_core::{ + execution::traceroute_serial, DestinationInfo, ProbeResponse, Protocol, PublicIpInfo, + ResultDestination, Results, SourceInfo, Stats, TcpMethod, TracerouteConfig, TracerouteDriver, + TracerouteError, TracerouteHop, TracerouteResults, TracerouteRun, +}; +use traceroute_icmp::IcmpDriver; +use traceroute_packets::new_source_sink; +use traceroute_tcp::TcpDriver; +use traceroute_udp::UdpDriver; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +/// Get the local IP address for connecting to the target. +fn get_local_addr(target: IpAddr) -> Result { + // Create a socket to determine the local IP address + let socket = match target { + IpAddr::V4(_) => std::net::UdpSocket::bind("0.0.0.0:0"), + IpAddr::V6(_) => std::net::UdpSocket::bind("[::]:0"), + } + .map_err(TracerouteError::SocketCreation)?; + + // Connect to the target to determine our local address + let port = 33434; + socket + .connect(SocketAddr::new(target, port)) + .map_err(TracerouteError::SocketCreation)?; + + socket + .local_addr() + .map(|addr| addr.ip()) + .map_err(TracerouteError::SocketCreation) +} + +/// Allocate a local port. +fn allocate_port(is_v6: bool) -> Result { + let socket = if is_v6 { + std::net::UdpSocket::bind("[::]:0") + } else { + std::net::UdpSocket::bind("0.0.0.0:0") + } + .map_err(TracerouteError::SocketCreation)?; + + socket + .local_addr() + .map(|addr| addr.port()) + .map_err(TracerouteError::SocketCreation) +} + +/// Resolve a hostname to an IP address. +pub async fn resolve_hostname(hostname: &str, want_v6: bool) -> Result { + // First check if it's already an IP address + if let Ok(ip) = hostname.parse::() { + return Ok(ip); + } + + let resolver = TokioAsyncResolver::tokio_from_system_conf() + .map_err(|e| TracerouteError::Internal(format!("Failed to create DNS resolver: {}", e)))?; + + let lookup = resolver.lookup_ip(hostname).await.map_err(|e| { + TracerouteError::Internal(format!("Failed to resolve hostname '{}': {}", hostname, e)) + })?; + + // Find the appropriate IP version + for ip in lookup.iter() { + match (ip, want_v6) { + (IpAddr::V6(_), true) => return Ok(ip), + (IpAddr::V4(_), false) => return Ok(ip), + _ => continue, + } + } + + // Fall back to first address + lookup + .iter() + .next() + .ok_or_else(|| TracerouteError::Internal(format!("No addresses found for '{}'", hostname))) +} + +/// Run a single traceroute and return the results. +async fn run_traceroute_once( + config: &TracerouteConfig, + target_ip: IpAddr, + src_ip: IpAddr, + src_port: u16, +) -> Result>, TracerouteError> { + let handle = new_source_sink(target_ip, config.use_windows_driver).await?; + + let mut driver: Box = match config.protocol { + Protocol::Udp => Box::new(UdpDriver::new( + src_ip, + src_port, + target_ip, + config.port, + handle.source, + handle.sink, + )), + Protocol::Tcp => { + match config.tcp_method { + TcpMethod::Syn | TcpMethod::SynSocket => { + // For SYN mode, use the TcpDriver + Box::new(TcpDriver::new( + src_ip, + src_port, + target_ip, + config.port, + handle.source, + handle.sink, + true, // paris_mode + config.params.max_ttl, + )) + } + TcpMethod::Sack | TcpMethod::PreferSack => { + // For SACK mode, we need to first do a TCP handshake + // For now, fall back to SYN mode + warn!("SACK mode not fully implemented, falling back to SYN mode"); + Box::new(TcpDriver::new( + src_ip, + src_port, + target_ip, + config.port, + handle.source, + handle.sink, + true, + config.params.max_ttl, + )) + } + } + } + Protocol::Icmp => Box::new(IcmpDriver::new( + src_ip, + target_ip, + handle.source, + handle.sink, + config.params.min_ttl, + config.params.max_ttl, + )), + }; + + let results = traceroute_serial(driver.as_mut(), &config.params).await?; + driver.close().await?; + + Ok(results) +} + +/// Convert probe responses to TracerouteHop. +/// +/// Only includes hops up to the last response received (or all if none reached destination). +fn responses_to_hops(responses: Vec>, min_ttl: u8) -> Vec { + // Find the last hop that got a response (either intermediate or destination) + let last_response_idx = responses + .iter() + .rposition(|r| r.is_some()) + .unwrap_or(responses.len().saturating_sub(1)); + + responses + .into_iter() + .take(last_response_idx + 1) + .enumerate() + .map(|(i, response)| { + let ttl = min_ttl + i as u8; + match response { + Some(probe) => TracerouteHop { + ttl, + ip_address: Some(probe.ip), + rtt: Some(probe.rtt.as_secs_f64() * 1000.0), + reachable: true, + reverse_dns: Vec::new(), + }, + None => TracerouteHop { + ttl, + ip_address: None, + rtt: None, + reachable: false, + reverse_dns: Vec::new(), + }, + } + }) + .collect() +} + +/// Performs reverse DNS lookups for all hops. +async fn enrich_with_reverse_dns(hops: &mut [TracerouteHop]) { + let resolver = match TokioAsyncResolver::tokio_from_system_conf() { + Ok(r) => r, + Err(e) => { + warn!("Failed to create DNS resolver for reverse lookup: {}", e); + return; + } + }; + + for hop in hops.iter_mut() { + if let Some(ip) = hop.ip_address { + match resolver.reverse_lookup(ip).await { + Ok(names) => { + hop.reverse_dns = names.iter().map(|n| n.to_string()).collect(); + } + Err(_) => { + // Reverse DNS not available for this IP + } + } + } + } +} + +/// Check if an IP address is private. +fn is_private_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local(), + IpAddr::V6(v6) => v6.is_loopback(), + } +} + +/// Remove private hops from the results. +fn remove_private_hops(hops: &mut Vec) { + hops.retain(|hop| { + if let Some(ip) = hop.ip_address { + !is_private_ip(ip) + } else { + true // Keep unreachable hops + } + }); +} + +/// Fetch public IP address. +async fn fetch_public_ip() -> Option { + let urls = [ + "https://api.ipify.org", + "https://ipinfo.io/ip", + "https://checkip.amazonaws.com", + "https://ifconfig.me/ip", + "https://icanhazip.com", + ]; + + for url in &urls { + match reqwest::get(*url).await { + Ok(response) => { + if let Ok(text) = response.text().await { + let ip = text.trim().to_string(); + if !ip.is_empty() { + return Some(ip); + } + } + } + Err(_) => continue, + } + } + + None +} + +/// Run the full traceroute with all options. +pub async fn run_traceroute(config: TracerouteConfig) -> Result { + info!( + target = %config.hostname, + protocol = %config.protocol, + port = config.port, + "Starting traceroute" + ); + + // Resolve hostname to IP + let target_ip = resolve_hostname(&config.hostname, config.want_v6).await?; + debug!("Resolved {} to {}", config.hostname, target_ip); + + // Get local IP address + let src_ip = get_local_addr(target_ip)?; + debug!("Using local IP: {}", src_ip); + + // Collect runs + let mut runs = Vec::new(); + let mut errors = Vec::new(); + + for i in 0..config.traceroute_queries { + debug!( + "Running traceroute query {}/{}", + i + 1, + config.traceroute_queries + ); + + // Allocate a new port for each run + let src_port = allocate_port(target_ip.is_ipv6())?; + + match run_traceroute_once(&config, target_ip, src_ip, src_port).await { + Ok(responses) => { + let mut hops = responses_to_hops(responses, config.params.min_ttl); + + // Perform reverse DNS lookups if requested + if config.reverse_dns { + enrich_with_reverse_dns(&mut hops).await; + } + + // Remove private hops if requested + if config.skip_private_hops { + remove_private_hops(&mut hops); + } + + let run = TracerouteRun { + run_id: Uuid::new_v4().to_string(), + source: SourceInfo { + ip_address: Some(src_ip), + port: Some(src_port), + }, + destination: DestinationInfo { + ip_address: Some(target_ip), + port: Some(config.port), + reverse_dns: Vec::new(), + }, + hops, + }; + runs.push(run); + } + Err(e) => { + warn!("Traceroute query {} failed: {}", i + 1, e); + errors.push(e); + } + } + } + + if runs.is_empty() && !errors.is_empty() { + return Err(errors.remove(0)); + } + + // Calculate hop count statistics + let hop_count = if runs.len() > 1 { + let counts: Vec = runs + .iter() + .map(|r| r.hops.iter().filter(|h| h.reachable).count() as f64) + .collect(); + let avg = counts.iter().sum::() / counts.len() as f64; + let min = counts.iter().cloned().fold(f64::INFINITY, f64::min); + let max = counts.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + Some(Stats { avg, min, max }) + } else { + None + }; + + // Collect public IP if requested + let source = if config.collect_source_public_ip { + let public_ip = fetch_public_ip().await; + Some(PublicIpInfo { public_ip }) + } else { + None + }; + + Ok(Results { + protocol: config.protocol.to_string(), + source, + destination: ResultDestination { + hostname: config.hostname, + port: config.port, + }, + traceroute: TracerouteResults { runs, hop_count }, + e2e_probe: None, // TODO: Implement E2E probes + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn test_is_private_ip() { + assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))); + assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert!(is_private_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))); + assert!(is_private_ip(IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(!is_private_ip(IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)))); + } + + #[tokio::test] + async fn test_resolve_ip_address() { + let result = resolve_hostname("8.8.8.8", false).await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))); + } +} diff --git a/rust/crates/traceroute-cli/tests/e2e_test.rs b/rust/crates/traceroute-cli/tests/e2e_test.rs new file mode 100644 index 00000000..465125a9 --- /dev/null +++ b/rust/crates/traceroute-cli/tests/e2e_test.rs @@ -0,0 +1,1112 @@ +//! End-to-end tests for datadog-traceroute CLI and HTTP server. +//! +//! These tests run the actual traceroute binary and HTTP server against real targets +//! and comprehensively verify all output fields match expected behavior. +//! +//! Test categories: +//! - CLI tests: test_localhost_* and test_public_* (via CLI binary) +//! - Server tests: test_server_* (via HTTP API) + +use serde::Deserialize; +use std::net::IpAddr; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +// Test targets +const LOCALHOST_TARGET: &str = "127.0.0.1"; +const PUBLIC_TARGET: &str = "github.com"; +const PUBLIC_PORT: u16 = 443; + +// Test parameters +const NUM_TRACEROUTE_QUERIES: usize = 3; +const DEFAULT_UDP_PORT: u16 = 33434; +const DEFAULT_TCP_PORT: u16 = 33434; // CLI default, not SSH (22) or HTTPS (443) + +// Server test configuration +const SERVER_PORT: u16 = 13765; // Use non-standard port to avoid conflicts +const SERVER_STARTUP_DELAY_MS: u64 = 500; + +/// Results structure matching the JSON output. +#[derive(Debug, Deserialize)] +struct Results { + protocol: String, + #[allow(dead_code)] + source: Option, + destination: ResultDestination, + traceroute: TracerouteResults, + #[allow(dead_code)] + e2e_probe: Option, +} + +#[derive(Debug, Deserialize)] +struct PublicIpInfo { + #[allow(dead_code)] + public_ip: Option, +} + +#[derive(Debug, Deserialize)] +struct ResultDestination { + hostname: String, + port: u16, +} + +#[derive(Debug, Deserialize)] +struct TracerouteResults { + runs: Vec, + hop_count: Option, +} + +#[derive(Debug, Deserialize)] +struct TracerouteRun { + run_id: String, + source: SourceInfo, + destination: DestinationInfo, + hops: Vec, +} + +#[derive(Debug, Deserialize)] +struct SourceInfo { + ip_address: Option, + port: Option, +} + +#[derive(Debug, Deserialize)] +struct DestinationInfo { + ip_address: Option, + port: Option, + #[serde(default)] + #[allow(dead_code)] + reverse_dns: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +struct TracerouteHop { + ttl: u8, + ip_address: Option, + rtt: Option, + reachable: bool, + #[serde(default)] + #[allow(dead_code)] + reverse_dns: Vec, +} + +#[derive(Debug, Deserialize)] +struct Stats { + avg: f64, + min: f64, + max: f64, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct E2eProbeResults { + rtts: Vec, + packets_sent: u32, + packets_received: u32, + packet_loss_percentage: f32, + jitter: Option, + rtt: Option, +} + +/// Test configuration. +#[derive(Debug, Clone)] +struct TestConfig { + hostname: String, + port: Option, + protocol: String, + tcp_method: Option, + num_queries: usize, +} + +impl TestConfig { + fn test_name(&self) -> String { + let mut name = self.protocol.clone(); + if let Some(ref method) = self.tcp_method { + name.push('_'); + name.push_str(method); + } + name + } + + fn expected_port(&self) -> u16 { + self.port.unwrap_or(match self.protocol.as_str() { + "tcp" => DEFAULT_TCP_PORT, + _ => DEFAULT_UDP_PORT, + }) + } +} + +impl Default for TestConfig { + fn default() -> Self { + Self { + hostname: LOCALHOST_TARGET.to_string(), + port: None, + protocol: "udp".to_string(), + tcp_method: None, + num_queries: NUM_TRACEROUTE_QUERIES, + } + } +} + +// ============================================================================= +// Binary and Server Helpers +// ============================================================================= + +/// Get the CLI binary path. +fn get_cli_binary() -> String { + // Check EXECUTABLE environment variable first (set by CI) + if let Ok(executable) = std::env::var("EXECUTABLE") { + if std::path::Path::new(&executable).exists() { + return executable; + } + } + + let binary_name = if cfg!(target_os = "windows") { + "datadog-traceroute.exe" + } else { + "datadog-traceroute" + }; + + // Get workspace root from CARGO_MANIFEST_DIR (which points to the crate dir) + // We need to go up to the workspace root + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let workspace_root = std::path::Path::new(&manifest_dir) + .parent() // crates/ + .and_then(|p| p.parent()) // rust/ + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + // Try release build first + let release_path = workspace_root.join("target/release").join(binary_name); + if release_path.exists() { + return release_path.to_string_lossy().to_string(); + } + + // Try debug build + let debug_path = workspace_root.join("target/debug").join(binary_name); + if debug_path.exists() { + return debug_path.to_string_lossy().to_string(); + } + + // Also try relative paths (for when running from workspace root) + let release_path = format!("target/release/{}", binary_name); + if std::path::Path::new(&release_path).exists() { + return release_path; + } + + let debug_path = format!("target/debug/{}", binary_name); + if std::path::Path::new(&debug_path).exists() { + return debug_path; + } + + panic!( + "CLI binary not found. Please build with 'cargo build' or 'cargo build --release' first. \ + Searched in workspace root: {:?}, EXECUTABLE env: {:?}", + workspace_root, + std::env::var("EXECUTABLE").ok() + ); +} + +/// Get the server binary path. +fn get_server_binary() -> String { + // Check SERVER_EXECUTABLE environment variable first (set by CI) + if let Ok(executable) = std::env::var("SERVER_EXECUTABLE") { + if std::path::Path::new(&executable).exists() { + return executable; + } + } + + let binary_name = if cfg!(target_os = "windows") { + "datadog-traceroute-server.exe" + } else { + "datadog-traceroute-server" + }; + + // Get workspace root from CARGO_MANIFEST_DIR (which points to the crate dir) + // We need to go up to the workspace root + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string()); + let workspace_root = std::path::Path::new(&manifest_dir) + .parent() // crates/ + .and_then(|p| p.parent()) // rust/ + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::path::PathBuf::from(".")); + + // Try release build first + let release_path = workspace_root.join("target/release").join(binary_name); + if release_path.exists() { + return release_path.to_string_lossy().to_string(); + } + + // Try debug build + let debug_path = workspace_root.join("target/debug").join(binary_name); + if debug_path.exists() { + return debug_path.to_string_lossy().to_string(); + } + + // Also try relative paths (for when running from workspace root) + let release_path = format!("target/release/{}", binary_name); + if std::path::Path::new(&release_path).exists() { + return release_path; + } + + let debug_path = format!("target/debug/{}", binary_name); + if std::path::Path::new(&debug_path).exists() { + return debug_path; + } + + panic!( + "Server binary not found. Please build with 'cargo build' or 'cargo build --release' first. \ + Searched in workspace root: {:?}, SERVER_EXECUTABLE env: {:?}", + workspace_root, + std::env::var("SERVER_EXECUTABLE").ok() + ); +} + +/// Start the HTTP server and return the child process. +fn start_server(port: u16) -> Result { + let binary = get_server_binary(); + let addr = format!("127.0.0.1:{}", port); + + let (cmd, args) = if cfg!(target_os = "windows") { + (binary.clone(), vec!["--addr".to_string(), addr]) + } else { + ("sudo".to_string(), vec![binary, "--addr".to_string(), addr]) + }; + + let child = Command::new(&cmd) + .args(&args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to start server: {}", e))?; + + // Give the server time to start + std::thread::sleep(Duration::from_millis(SERVER_STARTUP_DELAY_MS)); + + Ok(child) +} + +/// Stop the HTTP server. +fn stop_server(mut child: Child) { + if cfg!(target_os = "windows") { + let _ = child.kill(); + } else { + // On Unix, we need to kill the sudo process which will kill the server + let _ = Command::new("sudo") + .args(["kill", &child.id().to_string()]) + .output(); + } + let _ = child.wait(); +} + +// ============================================================================= +// CLI Runner +// ============================================================================= + +/// Run traceroute CLI and parse the output. +fn run_traceroute_cli(config: &TestConfig) -> Result { + let binary = get_cli_binary(); + + let mut args = vec![ + "--verbose".to_string(), // Enable debug logging + "--traceroute-queries".to_string(), + config.num_queries.to_string(), + "--proto".to_string(), + config.protocol.to_lowercase(), + ]; + + if let Some(port) = config.port { + args.push("--port".to_string()); + args.push(port.to_string()); + } + + if let Some(ref method) = config.tcp_method { + args.push("--tcp-method".to_string()); + args.push(method.clone()); + } + + args.push(config.hostname.clone()); + + // On Unix, we need sudo for raw socket access + let (cmd, final_args) = if cfg!(target_os = "windows") { + (binary.clone(), args) + } else { + let mut sudo_args = vec![binary.clone()]; + sudo_args.extend(args); + ("sudo".to_string(), sudo_args) + }; + + eprintln!("Running: {} {:?}", cmd, final_args); + + // Use spawn + wait_with_output to avoid blocking forever + // Add a timeout to prevent hanging indefinitely + let mut child = Command::new(&cmd) + .args(&final_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn command: {}", e))?; + + eprintln!("Process spawned with PID: {:?}", child.id()); + + // Wait for the process with a timeout + use std::time::Duration; + let timeout = Duration::from_secs(120); // 2 minute timeout per CLI invocation + let start = std::time::Instant::now(); + + let output = loop { + match child.try_wait() { + Ok(Some(status)) => { + // Process has exited + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + if let Some(mut out) = child.stdout.take() { + use std::io::Read; + let _ = out.read_to_end(&mut stdout); + } + if let Some(mut err) = child.stderr.take() { + use std::io::Read; + let _ = err.read_to_end(&mut stderr); + } + break std::process::Output { + status, + stdout, + stderr, + }; + } + Ok(None) => { + // Process still running + if start.elapsed() > timeout { + eprintln!("Process timed out after {:?}, killing...", timeout); + + // Capture stderr before killing to help diagnose hangs + let mut stderr_output = Vec::new(); + if let Some(mut err) = child.stderr.take() { + use std::io::Read; + // Non-blocking read of whatever is available + let _ = err.read_to_end(&mut stderr_output); + } + let stderr_str = String::from_utf8_lossy(&stderr_output); + if !stderr_str.is_empty() { + eprintln!("CLI stderr before timeout:\n{}", stderr_str); + } + + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("Process timed out after {:?}", timeout)); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => { + return Err(format!("Error waiting for process: {}", e)); + } + } + }; + + // Always print stderr for debugging + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprintln!("CLI stderr:\n{}", stderr); + } + + if !output.status.success() { + return Err(format!( + "Command failed with status {}:\n{}", + output.status, stderr + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + eprintln!("CLI stdout:\n{}", stdout); + serde_json::from_str(&stdout) + .map_err(|e| format!("Failed to parse JSON output: {}\nOutput: {}", e, stdout)) +} + +// ============================================================================= +// Server Runner +// ============================================================================= + +/// Run traceroute via HTTP server API. +fn run_traceroute_server(config: &TestConfig, server_port: u16) -> Result { + let mut url = format!( + "http://127.0.0.1:{}/traceroute?target={}&protocol={}&queries={}", + server_port, config.hostname, config.protocol, config.num_queries + ); + + if let Some(port) = config.port { + url.push_str(&format!("&port={}", port)); + } + + // Use curl for HTTP request (available on all platforms) + let output = Command::new("curl") + .args(["-s", "-f", &url]) + .output() + .map_err(|e| format!("Failed to run curl: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(format!( + "HTTP request failed:\nstderr: {}\nstdout: {}", + stderr, stdout + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(&stdout) + .map_err(|e| format!("Failed to parse JSON response: {}\nResponse: {}", e, stdout)) +} + +// ============================================================================= +// Comprehensive Validation +// ============================================================================= + +/// Comprehensive validation of traceroute results. +fn validate_results(results: &Results, config: &TestConfig, expect_destination_reachable: bool) { + validate_protocol(results, config); + validate_destination(results, config); + validate_traceroute_runs(results, config, expect_destination_reachable); + validate_hop_count_stats(results, config); +} + +/// Validate protocol field. +fn validate_protocol(results: &Results, config: &TestConfig) { + assert_eq!( + results.protocol.to_lowercase(), + config.protocol.to_lowercase(), + "Protocol should match config" + ); + + // Protocol should be one of the valid values + let valid_protocols = ["udp", "tcp", "icmp"]; + assert!( + valid_protocols.contains(&results.protocol.to_lowercase().as_str()), + "Protocol '{}' should be one of {:?}", + results.protocol, + valid_protocols + ); +} + +/// Validate destination fields. +fn validate_destination(results: &Results, config: &TestConfig) { + // Hostname should match exactly + assert_eq!( + results.destination.hostname, config.hostname, + "Destination hostname should match config" + ); + + // Port should match expected port + let expected_port = config.expected_port(); + assert_eq!( + results.destination.port, expected_port, + "Destination port should match expected port" + ); +} + +/// Validate all traceroute runs. +fn validate_traceroute_runs( + results: &Results, + config: &TestConfig, + expect_destination_reachable: bool, +) { + // Should have correct number of runs + assert_eq!( + results.traceroute.runs.len(), + config.num_queries, + "Should have {} traceroute runs", + config.num_queries + ); + + for (run_idx, run) in results.traceroute.runs.iter().enumerate() { + validate_run(run, run_idx, config, expect_destination_reachable); + } +} + +/// Validate a single traceroute run. +fn validate_run( + run: &TracerouteRun, + run_idx: usize, + config: &TestConfig, + expect_destination_reachable: bool, +) { + // Validate run_id (should be a valid UUID format) + validate_run_id(&run.run_id, run_idx); + + // Validate source info + validate_source_info(&run.source, run_idx); + + // Validate destination info + validate_destination_info(&run.destination, run_idx, config); + + // Validate hops + validate_hops(&run.hops, run_idx, config, expect_destination_reachable); +} + +/// Validate run_id is a valid UUID format. +fn validate_run_id(run_id: &str, run_idx: usize) { + assert!( + !run_id.is_empty(), + "Run {} run_id should not be empty", + run_idx + ); + + // UUID format: 8-4-4-4-12 hex characters + let uuid_regex = + regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + .unwrap(); + assert!( + uuid_regex.is_match(run_id), + "Run {} run_id '{}' should be a valid UUID format", + run_idx, + run_id + ); +} + +/// Validate source info fields. +fn validate_source_info(source: &SourceInfo, run_idx: usize) { + // Source IP should be present + assert!( + source.ip_address.is_some(), + "Run {} should have source IP address", + run_idx + ); + + // Source port should be present and valid (1-65535) + assert!( + source.port.is_some(), + "Run {} should have source port", + run_idx + ); + let port = source.port.unwrap(); + assert!( + port > 0, + "Run {} source port {} should be > 0", + run_idx, + port + ); +} + +/// Validate destination info fields. +fn validate_destination_info(dest: &DestinationInfo, run_idx: usize, config: &TestConfig) { + // Destination IP should be present (resolved from hostname) + assert!( + dest.ip_address.is_some(), + "Run {} should have destination IP address", + run_idx + ); + + // Destination port should match config + let expected_port = config.expected_port(); + assert!( + dest.port.is_some(), + "Run {} should have destination port", + run_idx + ); + assert_eq!( + dest.port.unwrap(), + expected_port, + "Run {} destination port should match config", + run_idx + ); +} + +/// Validate hops in a run. +fn validate_hops( + hops: &[TracerouteHop], + run_idx: usize, + config: &TestConfig, + expect_destination_reachable: bool, +) { + // Should have at least one hop + assert!( + !hops.is_empty(), + "Run {} should have at least one hop", + run_idx + ); + + // Validate each hop + let mut expected_ttl = 1u8; + for (hop_idx, hop) in hops.iter().enumerate() { + validate_hop(hop, run_idx, hop_idx, expected_ttl); + expected_ttl += 1; + } + + // For localhost, check destination reachability + if expect_destination_reachable && config.hostname == LOCALHOST_TARGET { + let last_hop = hops.last().unwrap(); + assert!( + last_hop.reachable, + "Run {} last hop should be reachable for localhost", + run_idx + ); + } +} + +/// Validate a single hop. +fn validate_hop(hop: &TracerouteHop, run_idx: usize, hop_idx: usize, expected_ttl: u8) { + // TTL should be sequential starting from 1 + assert_eq!( + hop.ttl, expected_ttl, + "Run {}, hop {} TTL should be {} (sequential)", + run_idx, hop_idx, expected_ttl + ); + + // If reachable, should have IP and positive RTT + if hop.reachable { + assert!( + hop.ip_address.is_some(), + "Run {}, hop {} (TTL {}) is reachable but has no IP address", + run_idx, + hop_idx, + hop.ttl + ); + + // RTT should be present and non-negative + assert!( + hop.rtt.is_some(), + "Run {}, hop {} (TTL {}) is reachable but has no RTT", + run_idx, + hop_idx, + hop.ttl + ); + let rtt = hop.rtt.unwrap(); + assert!( + rtt >= 0.0, + "Run {}, hop {} (TTL {}) RTT {} should be non-negative", + run_idx, + hop_idx, + hop.ttl, + rtt + ); + } +} + +/// Validate hop_count statistics when multiple runs. +fn validate_hop_count_stats(results: &Results, config: &TestConfig) { + if config.num_queries > 1 { + // With multiple runs, hop_count stats should be present + assert!( + results.traceroute.hop_count.is_some(), + "hop_count stats should be present with {} runs", + config.num_queries + ); + + let stats = results.traceroute.hop_count.as_ref().unwrap(); + + // min <= avg <= max + assert!( + stats.min <= stats.avg, + "hop_count min ({}) should be <= avg ({})", + stats.min, + stats.avg + ); + assert!( + stats.avg <= stats.max, + "hop_count avg ({}) should be <= max ({})", + stats.avg, + stats.max + ); + + // All values should be non-negative + assert!( + stats.min >= 0.0, + "hop_count min ({}) should be non-negative", + stats.min + ); + assert!( + stats.avg >= 0.0, + "hop_count avg ({}) should be non-negative", + stats.avg + ); + assert!( + stats.max >= 0.0, + "hop_count max ({}) should be non-negative", + stats.max + ); + } +} + +// ============================================================================= +// CLI Tests - Localhost +// ============================================================================= + +#[test] +#[ignore] // Requires root privileges +fn test_localhost_icmp() { + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "icmp".to_string(), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + validate_results(&results, &config, true); +} + +#[test] +#[ignore] // Requires root privileges +fn test_localhost_udp() { + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "udp".to_string(), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + validate_results(&results, &config, true); +} + +#[test] +#[ignore] // Requires root privileges +fn test_localhost_tcp_syn() { + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "tcp".to_string(), + tcp_method: Some("syn".to_string()), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + validate_results(&results, &config, true); +} + +// ============================================================================= +// CLI Tests - Public Target +// ============================================================================= + +#[test] +#[ignore] // Requires root privileges and network access +fn test_public_icmp() { + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "icmp".to_string(), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + // Public targets may not always be reachable + validate_results(&results, &config, false); +} + +#[test] +#[ignore] // Requires root privileges and network access +fn test_public_udp() { + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "udp".to_string(), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + validate_results(&results, &config, false); +} + +#[test] +#[ignore] // Requires root privileges and network access +fn test_public_tcp_syn() { + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "tcp".to_string(), + tcp_method: Some("syn".to_string()), + ..Default::default() + }; + + let results = run_traceroute_cli(&config) + .expect(&format!("CLI test {} should succeed", config.test_name())); + // TCP SYN should reach github.com:443 + validate_results(&results, &config, true); +} + +// ============================================================================= +// Server Tests - Localhost +// ============================================================================= + +#[test] +#[ignore] // Requires root privileges +fn test_server_localhost_icmp() { + let server = start_server(SERVER_PORT).expect("Server should start"); + + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "icmp".to_string(), + num_queries: 2, // Fewer queries for faster server tests + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, true); +} + +#[test] +#[ignore] // Requires root privileges +fn test_server_localhost_udp() { + let server = start_server(SERVER_PORT + 1).expect("Server should start"); + + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "udp".to_string(), + num_queries: 2, + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT + 1); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, true); +} + +#[test] +#[ignore] // Requires root privileges +fn test_server_localhost_tcp() { + let server = start_server(SERVER_PORT + 2).expect("Server should start"); + + let config = TestConfig { + hostname: LOCALHOST_TARGET.to_string(), + protocol: "tcp".to_string(), + num_queries: 2, + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT + 2); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, true); +} + +// ============================================================================= +// Server Tests - Public Target +// ============================================================================= + +#[test] +#[ignore] // Requires root privileges and network access +fn test_server_public_icmp() { + let server = start_server(SERVER_PORT + 3).expect("Server should start"); + + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "icmp".to_string(), + num_queries: 2, + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT + 3); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, false); +} + +#[test] +#[ignore] // Requires root privileges and network access +fn test_server_public_udp() { + let server = start_server(SERVER_PORT + 4).expect("Server should start"); + + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "udp".to_string(), + num_queries: 2, + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT + 4); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, false); +} + +#[test] +#[ignore] // Requires root privileges and network access +fn test_server_public_tcp() { + let server = start_server(SERVER_PORT + 5).expect("Server should start"); + + let config = TestConfig { + hostname: PUBLIC_TARGET.to_string(), + port: Some(PUBLIC_PORT), + protocol: "tcp".to_string(), + num_queries: 2, + ..Default::default() + }; + + let result = run_traceroute_server(&config, SERVER_PORT + 5); + stop_server(server); + + let results = result.expect(&format!( + "Server test {} should succeed", + config.test_name() + )); + validate_results(&results, &config, true); +} + +// ============================================================================= +// Server Health Check Test +// ============================================================================= + +#[test] +#[ignore] // Requires root privileges +fn test_server_health_endpoint() { + let server = start_server(SERVER_PORT + 6).expect("Server should start"); + + let url = format!("http://127.0.0.1:{}/health", SERVER_PORT + 6); + let output = Command::new("curl") + .args(["-s", "-f", &url]) + .output() + .expect("curl should succeed"); + + stop_server(server); + + assert!(output.status.success(), "Health endpoint should return 200"); + let body = String::from_utf8_lossy(&output.stdout); + assert_eq!(body.trim(), "ok", "Health endpoint should return 'ok'"); +} + +// ============================================================================= +// Unit Tests (no root required) +// ============================================================================= + +#[test] +fn test_json_parsing_full() { + let json = r#"{ + "protocol": "udp", + "source": {"public_ip": "1.2.3.4"}, + "destination": {"hostname": "example.com", "port": 33434}, + "traceroute": { + "runs": [{ + "run_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "source": {"ip_address": "192.168.1.1", "port": 12345}, + "destination": {"ip_address": "8.8.8.8", "port": 33434, "reverse_dns": []}, + "hops": [ + {"ttl": 1, "ip_address": "192.168.1.254", "rtt": 1.5, "reachable": true, "reverse_dns": []}, + {"ttl": 2, "ip_address": "10.0.0.1", "rtt": 5.2, "reachable": true, "reverse_dns": []}, + {"ttl": 3, "ip_address": null, "rtt": null, "reachable": false, "reverse_dns": []}, + {"ttl": 4, "ip_address": "8.8.8.8", "rtt": 15.3, "reachable": true, "reverse_dns": ["dns.google"]} + ] + }], + "hop_count": null + } + }"#; + + let results: Results = serde_json::from_str(json).expect("Failed to parse JSON"); + + // Validate parsed fields + assert_eq!(results.protocol, "udp"); + assert_eq!(results.destination.hostname, "example.com"); + assert_eq!(results.destination.port, 33434); + assert_eq!(results.traceroute.runs.len(), 1); + + let run = &results.traceroute.runs[0]; + assert_eq!(run.run_id, "a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + assert!(run.source.ip_address.is_some()); + assert_eq!(run.source.port, Some(12345)); + assert!(run.destination.ip_address.is_some()); + assert_eq!(run.destination.port, Some(33434)); + + // Validate hops + assert_eq!(run.hops.len(), 4); + assert_eq!(run.hops[0].ttl, 1); + assert!(run.hops[0].reachable); + assert!(run.hops[0].rtt.is_some()); + assert_eq!(run.hops[2].ttl, 3); + assert!(!run.hops[2].reachable); + assert!(run.hops[2].ip_address.is_none()); +} + +#[test] +fn test_json_parsing_with_hop_count_stats() { + let json = r#"{ + "protocol": "tcp", + "destination": {"hostname": "test.com", "port": 443}, + "traceroute": { + "runs": [ + { + "run_id": "11111111-1111-1111-1111-111111111111", + "source": {"ip_address": "192.168.1.1", "port": 10000}, + "destination": {"ip_address": "1.2.3.4", "port": 443}, + "hops": [{"ttl": 1, "ip_address": "192.168.1.1", "rtt": 1.0, "reachable": true}] + }, + { + "run_id": "22222222-2222-2222-2222-222222222222", + "source": {"ip_address": "192.168.1.1", "port": 10001}, + "destination": {"ip_address": "1.2.3.4", "port": 443}, + "hops": [{"ttl": 1, "ip_address": "192.168.1.1", "rtt": 2.0, "reachable": true}] + } + ], + "hop_count": {"avg": 5.5, "min": 4.0, "max": 7.0} + } + }"#; + + let results: Results = serde_json::from_str(json).expect("Failed to parse JSON"); + + assert_eq!(results.traceroute.runs.len(), 2); + assert!(results.traceroute.hop_count.is_some()); + + let stats = results.traceroute.hop_count.as_ref().unwrap(); + assert_eq!(stats.avg, 5.5); + assert_eq!(stats.min, 4.0); + assert_eq!(stats.max, 7.0); +} + +#[test] +fn test_validate_uuid_format() { + // Valid UUIDs + let valid_uuids = [ + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + ]; + + let uuid_regex = + regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + .unwrap(); + + for uuid in &valid_uuids { + assert!(uuid_regex.is_match(uuid), "UUID '{}' should be valid", uuid); + } + + // Invalid UUIDs + let invalid_uuids = [ + "", + "not-a-uuid", + "a1b2c3d4-e5f6-7890-abcd", // Too short + "a1b2c3d4-e5f6-7890-abcd-ef1234567890-extra", // Too long + "A1B2C3D4-E5F6-7890-ABCD-EF1234567890", // Uppercase (should be lowercase) + ]; + + for uuid in &invalid_uuids { + assert!( + !uuid_regex.is_match(uuid), + "UUID '{}' should be invalid", + uuid + ); + } +} diff --git a/rust/crates/traceroute-core/Cargo.toml b/rust/crates/traceroute-core/Cargo.toml new file mode 100644 index 00000000..fad10cb7 --- /dev/null +++ b/rust/crates/traceroute-core/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "traceroute-core" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Core types, traits, and error handling for datadog-traceroute" + +[dependencies] +tokio = { workspace = true } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } +proptest = { workspace = true } diff --git a/rust/crates/traceroute-core/src/error.rs b/rust/crates/traceroute-core/src/error.rs new file mode 100644 index 00000000..40be110b --- /dev/null +++ b/rust/crates/traceroute-core/src/error.rs @@ -0,0 +1,137 @@ +//! Error types for traceroute operations. + +use std::net::IpAddr; +use thiserror::Error; + +/// Main error type for traceroute operations. +#[derive(Error, Debug)] +pub enum TracerouteError { + // Socket/IO errors + #[error("Failed to create socket: {0}")] + SocketCreation(#[source] std::io::Error), + + #[error("Failed to bind to address {addr}: {source}")] + SocketBind { + addr: IpAddr, + #[source] + source: std::io::Error, + }, + + #[error("Read timeout exceeded")] + ReadTimeout, + + #[error("Write failed: {0}")] + WriteFailed(#[source] std::io::Error), + + // Packet errors + #[error("Packet too short: expected at least {expected} bytes, got {actual}")] + PacketTooShort { expected: usize, actual: usize }, + + #[error("Failed to parse {layer} layer: {reason}")] + PacketParseFailed { layer: &'static str, reason: String }, + + #[error("Packet did not match traceroute")] + PacketMismatch, + + #[error("Malformed packet: {0}")] + MalformedPacket(String), + + // Protocol errors + #[error("SACK not supported by target {target}")] + SackNotSupported { target: IpAddr }, + + #[error("Handshake timeout")] + HandshakeTimeout, + + #[error("Connection refused by {target}")] + ConnectionRefused { target: IpAddr }, + + // Driver errors + #[error("Driver not available on this platform")] + DriverNotAvailable, + + #[error("Driver initialization failed: {0}")] + DriverInitFailed(String), + + #[error("Parallel execution not supported by this driver")] + ParallelNotSupported, + + // DNS errors + #[error("Failed to resolve hostname {hostname}: {source}")] + DnsResolutionFailed { + hostname: String, + #[source] + source: Box, + }, + + // Configuration errors + #[error("Invalid TTL range: min={min_ttl}, max={max_ttl}")] + InvalidTtlRange { min_ttl: u8, max_ttl: u8 }, + + #[error("Invalid port: {0}")] + InvalidPort(u16), + + #[error("Unknown protocol: {0}")] + UnknownProtocol(String), + + // Internal errors + #[error("Internal error: {0}")] + Internal(String), + + #[error("Operation cancelled")] + Cancelled, +} + +impl TracerouteError { + /// Returns true if this error is retryable (e.g., timeout, packet mismatch, parse failure). + /// + /// Retryable errors indicate that we should continue reading packets rather than + /// giving up. This is important because raw sockets may capture packets that aren't + /// relevant to our traceroute (e.g., other traffic on the network). + pub fn is_retryable(&self) -> bool { + matches!( + self, + Self::ReadTimeout + | Self::PacketMismatch + | Self::MalformedPacket(_) + | Self::PacketParseFailed { .. } + | Self::PacketTooShort { .. } + ) + } +} + +impl From for TracerouteError { + fn from(err: std::io::Error) -> Self { + match err.kind() { + std::io::ErrorKind::TimedOut => TracerouteError::ReadTimeout, + std::io::ErrorKind::WouldBlock => TracerouteError::ReadTimeout, + _ => TracerouteError::Internal(err.to_string()), + } + } +} + +/// Result type alias for traceroute operations. +pub type TracerouteResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_retryable_errors() { + assert!(TracerouteError::ReadTimeout.is_retryable()); + assert!(TracerouteError::PacketMismatch.is_retryable()); + assert!(TracerouteError::MalformedPacket("test".into()).is_retryable()); + assert!(TracerouteError::PacketParseFailed { + layer: "IP", + reason: "test".into() + } + .is_retryable()); + assert!(TracerouteError::PacketTooShort { + expected: 20, + actual: 10 + } + .is_retryable()); + assert!(!TracerouteError::DriverNotAvailable.is_retryable()); + } +} diff --git a/rust/crates/traceroute-core/src/execution/mod.rs b/rust/crates/traceroute-core/src/execution/mod.rs new file mode 100644 index 00000000..06e6a5ba --- /dev/null +++ b/rust/crates/traceroute-core/src/execution/mod.rs @@ -0,0 +1,9 @@ +//! Execution modes for traceroute. +//! +//! Provides both serial and parallel execution strategies. + +pub mod parallel; +pub mod serial; + +pub use parallel::traceroute_parallel; +pub use serial::traceroute_serial; diff --git a/rust/crates/traceroute-core/src/execution/parallel.rs b/rust/crates/traceroute-core/src/execution/parallel.rs new file mode 100644 index 00000000..2fdda0e5 --- /dev/null +++ b/rust/crates/traceroute-core/src/execution/parallel.rs @@ -0,0 +1,130 @@ +//! Parallel traceroute execution. +//! +//! Sends all probes in quick succession and collects responses asynchronously. + +use crate::{ProbeResponse, TracerouteDriver, TracerouteError, TracerouteParams}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, trace}; + +/// Executes a traceroute using parallel probe sending. +/// +/// This sends all probes in quick succession (with `send_delay` between them) +/// and collects responses asynchronously. More efficient for high-latency networks. +pub async fn traceroute_parallel( + driver: &mut D, + params: &TracerouteParams, +) -> Result>, TracerouteError> { + params.validate()?; + + let driver_info = driver.get_driver_info(); + if !driver_info.supports_parallel { + return Err(TracerouteError::ParallelNotSupported); + } + + let results = Arc::new(Mutex::new(vec![None; params.max_ttl as usize + 1])); + let found_dest = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let max_timeout = params.max_timeout(); + + // We need to run sender and receiver concurrently + // Since we only have one driver, we'll use a different approach: + // Send all probes first, then receive + + // Phase 1: Send all probes + debug!("Phase 1: Sending all probes"); + for ttl in params.min_ttl..=params.max_ttl { + trace!(ttl = ttl, "Sending probe"); + driver.send_probe(ttl).await?; + tokio::time::sleep(params.send_delay).await; + } + + // Phase 2: Receive responses until timeout or destination found + debug!("Phase 2: Receiving responses"); + let receive_deadline = tokio::time::Instant::now() + max_timeout; + + while tokio::time::Instant::now() < receive_deadline { + if found_dest.load(std::sync::atomic::Ordering::Relaxed) { + break; + } + + match driver.receive_probe(params.poll_frequency).await { + Ok(Some(probe)) => { + let ttl_idx = probe.ttl as usize; + let is_dest = probe.is_dest; + debug!( + ttl = probe.ttl, + ip = %probe.ip, + rtt_ms = probe.rtt.as_secs_f64() * 1000.0, + is_dest = is_dest, + "Received probe response" + ); + + let mut results_guard = results.lock().await; + let existing = &results_guard[ttl_idx]; + + // Only update if we don't have a response yet, or if we're upgrading + // from an ICMP response to a destination response + if existing.is_none() + || (is_dest + && !existing + .as_ref() + .map(|p: &ProbeResponse| p.is_dest) + .unwrap_or(false)) + { + if is_dest { + found_dest.store(true, std::sync::atomic::Ordering::Relaxed); + } + results_guard[ttl_idx] = Some(probe); + } + } + Ok(None) => { + // No packet available, continue polling + continue; + } + Err(e) if e.is_retryable() => { + trace!(error = %e, "Retryable error, continuing"); + continue; + } + Err(e) => { + debug!(error = %e, "Fatal error during receive"); + return Err(e); + } + } + } + + let final_results = Arc::try_unwrap(results) + .map_err(|_| TracerouteError::Internal("Failed to unwrap results".into()))? + .into_inner(); + + Ok(clip_results(params.min_ttl, final_results)) +} + +/// Clips the results vector to remove leading None entries before min_ttl. +fn clip_results(min_ttl: u8, results: Vec>) -> Vec> { + results.into_iter().skip(min_ttl as usize).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_results() { + let results = vec![ + None, + Some(ProbeResponse { + ttl: 1, + ip: "10.0.0.1".parse().unwrap(), + rtt: std::time::Duration::from_millis(10), + is_dest: false, + }), + None, + None, + ]; + + let clipped = clip_results(1, results); + assert_eq!(clipped.len(), 3); + assert!(clipped[0].is_some()); + } +} diff --git a/rust/crates/traceroute-core/src/execution/serial.rs b/rust/crates/traceroute-core/src/execution/serial.rs new file mode 100644 index 00000000..3f7c15ef --- /dev/null +++ b/rust/crates/traceroute-core/src/execution/serial.rs @@ -0,0 +1,108 @@ +//! Serial traceroute execution. +//! +//! Sends one probe at a time and waits for a response before sending the next. + +use crate::{ProbeResponse, TracerouteDriver, TracerouteError, TracerouteParams}; +use tokio::time::timeout; +use tracing::{debug, trace}; + +/// Executes a traceroute using serial probe sending. +/// +/// This sends one probe at a time and waits for a response (or timeout) +/// before sending the next probe. This is simpler but slower than parallel mode. +pub async fn traceroute_serial( + driver: &mut D, + params: &TracerouteParams, +) -> Result>, TracerouteError> { + params.validate()?; + + let mut results = vec![None; params.max_ttl as usize + 1]; + + for ttl in params.min_ttl..=params.max_ttl { + let send_time = tokio::time::Instant::now(); + + debug!(ttl = ttl, "Sending probe"); + driver.send_probe(ttl).await?; + + // Wait for response with timeout + let probe_result = timeout(params.timeout, async { + loop { + match driver.receive_probe(params.poll_frequency).await { + Ok(Some(p)) => return Ok(p), + Ok(None) => continue, + Err(e) if e.is_retryable() => { + trace!(error = %e, "Retryable error, continuing"); + continue; + } + Err(e) => return Err(e), + } + } + }) + .await; + + match probe_result { + Ok(Ok(probe)) => { + let is_dest = probe.is_dest; + let ttl_idx = probe.ttl as usize; + debug!( + ttl = probe.ttl, + ip = %probe.ip, + rtt_ms = probe.rtt.as_secs_f64() * 1000.0, + is_dest = is_dest, + "Received probe response" + ); + results[ttl_idx] = Some(probe); + if is_dest { + debug!("Reached destination, stopping"); + break; + } + } + Ok(Err(e)) => { + debug!(ttl = ttl, error = %e, "Fatal error during receive"); + return Err(e); + } + Err(_) => { + debug!(ttl = ttl, "Timeout waiting for response"); + // Timeout, leave as None + } + } + + // Ensure minimum delay between probes + let elapsed = send_time.elapsed(); + if elapsed < params.send_delay { + tokio::time::sleep(params.send_delay - elapsed).await; + } + } + + // Clip results to only include valid TTL range + Ok(clip_results(params.min_ttl, results)) +} + +/// Clips the results vector to remove leading None entries before min_ttl. +fn clip_results(min_ttl: u8, results: Vec>) -> Vec> { + results.into_iter().skip(min_ttl as usize).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_results() { + let results = vec![ + None, + Some(ProbeResponse { + ttl: 1, + ip: "10.0.0.1".parse().unwrap(), + rtt: std::time::Duration::from_millis(10), + is_dest: false, + }), + None, + None, + ]; + + let clipped = clip_results(1, results); + assert_eq!(clipped.len(), 3); + assert!(clipped[0].is_some()); + } +} diff --git a/rust/crates/traceroute-core/src/lib.rs b/rust/crates/traceroute-core/src/lib.rs new file mode 100644 index 00000000..902ea743 --- /dev/null +++ b/rust/crates/traceroute-core/src/lib.rs @@ -0,0 +1,23 @@ +//! Core types, traits, and error handling for datadog-traceroute. +//! +//! This crate provides the fundamental abstractions used throughout the +//! traceroute implementation: +//! +//! - [`TracerouteDriver`] trait for protocol implementations +//! - [`ProbeResponse`] and other core types +//! - [`TracerouteError`] for error handling +//! - Result types for traceroute output + +pub mod error; +pub mod execution; +pub mod result; +pub mod traits; +pub mod types; + +pub use error::TracerouteError; +pub use result::{ + DestinationInfo, PublicIpInfo, ResultDestination, Results, SourceInfo, Stats, TracerouteHop, + TracerouteResults, TracerouteRun, +}; +pub use traits::{TracerouteDriver, TracerouteDriverInfo}; +pub use types::{ProbeResponse, Protocol, TcpMethod, TracerouteConfig, TracerouteParams}; diff --git a/rust/crates/traceroute-core/src/result.rs b/rust/crates/traceroute-core/src/result.rs new file mode 100644 index 00000000..164e410b --- /dev/null +++ b/rust/crates/traceroute-core/src/result.rs @@ -0,0 +1,177 @@ +//! Result types for traceroute output. +//! +//! These types match the JSON output format of the Go implementation +//! to maintain API compatibility. + +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +/// A single hop in a traceroute. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TracerouteHop { + /// The TTL for this hop. + pub ttl: u8, + /// The IP address that responded (None if no response). + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Round-trip time in milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub rtt: Option, + /// Whether this hop was reachable. + pub reachable: bool, + /// Reverse DNS names for this hop. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reverse_dns: Vec, +} + +/// Source information for a traceroute run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceInfo { + /// Source IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Source port. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// Destination information for a traceroute run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DestinationInfo { + /// Destination IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip_address: Option, + /// Destination port. + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + /// Reverse DNS names for the destination. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reverse_dns: Vec, +} + +/// A single traceroute run. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TracerouteRun { + /// Unique identifier for this run. + pub run_id: String, + /// Source information. + pub source: SourceInfo, + /// Destination information. + pub destination: DestinationInfo, + /// The hops discovered in this run. + pub hops: Vec, +} + +/// Statistics for numeric values. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stats { + /// Average value. + pub avg: f64, + /// Minimum value. + pub min: f64, + /// Maximum value. + pub max: f64, +} + +/// Aggregated traceroute results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TracerouteResults { + /// Individual traceroute runs. + pub runs: Vec, + /// Hop count statistics. + #[serde(skip_serializing_if = "Option::is_none")] + pub hop_count: Option, +} + +/// End-to-end probe results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct E2eProbeResults { + /// Individual RTT measurements. + pub rtts: Vec, + /// Number of packets sent. + pub packets_sent: u32, + /// Number of packets received. + pub packets_received: u32, + /// Packet loss percentage. + pub packet_loss_percentage: f32, + /// Jitter (RTT variance). + #[serde(skip_serializing_if = "Option::is_none")] + pub jitter: Option, + /// RTT statistics. + #[serde(skip_serializing_if = "Option::is_none")] + pub rtt: Option, +} + +/// Public IP information for the source. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicIpInfo { + /// The public IP address. + #[serde(skip_serializing_if = "Option::is_none")] + pub public_ip: Option, +} + +/// High-level destination info for the results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultDestination { + /// Target hostname. + pub hostname: String, + /// Target port. + pub port: u16, +} + +/// Complete traceroute results. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Results { + /// Protocol used. + pub protocol: String, + /// Source public IP information. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// Destination information. + pub destination: ResultDestination, + /// Traceroute results. + pub traceroute: TracerouteResults, + /// End-to-end probe results. + #[serde(skip_serializing_if = "Option::is_none")] + pub e2e_probe: Option, +} + +impl Results { + /// Serializes the results to JSON with indentation. + pub fn to_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Serializes the results to compact JSON. + pub fn to_json_compact(&self) -> Result { + serde_json::to_string(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_results_serialization() { + let results = Results { + protocol: "udp".to_string(), + source: Some(PublicIpInfo { + public_ip: Some("1.2.3.4".to_string()), + }), + destination: ResultDestination { + hostname: "example.com".to_string(), + port: 33434, + }, + traceroute: TracerouteResults { + runs: vec![], + hop_count: None, + }, + e2e_probe: None, + }; + + let json = results.to_json().unwrap(); + assert!(json.contains("\"protocol\": \"udp\"")); + assert!(json.contains("\"hostname\": \"example.com\"")); + } +} diff --git a/rust/crates/traceroute-core/src/traits.rs b/rust/crates/traceroute-core/src/traits.rs new file mode 100644 index 00000000..08a8ba69 --- /dev/null +++ b/rust/crates/traceroute-core/src/traits.rs @@ -0,0 +1,50 @@ +//! Core traits for traceroute driver implementations. + +use crate::{ProbeResponse, TracerouteError}; +use async_trait::async_trait; +use std::time::Duration; + +/// Metadata about a TracerouteDriver implementation. +#[derive(Debug, Clone, Copy)] +pub struct TracerouteDriverInfo { + /// Whether this driver supports parallel probe sending. + pub supports_parallel: bool, +} + +/// Core trait for traceroute implementations (TCP, UDP, ICMP, SACK). +/// +/// Each protocol driver implements this trait to provide a consistent +/// interface for sending probes and receiving responses. +#[async_trait] +pub trait TracerouteDriver: Send + Sync { + /// Returns metadata about this driver. + fn get_driver_info(&self) -> TracerouteDriverInfo; + + /// Sends a traceroute probe with the specified TTL. + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError>; + + /// Receives a probe response with timeout. + /// + /// Returns `Ok(None)` if no matching response was received within the timeout. + /// Returns `Err` for fatal errors that should stop the traceroute. + async fn receive_probe( + &mut self, + timeout: Duration, + ) -> Result, TracerouteError>; + + /// Closes the driver, releasing resources. + async fn close(&mut self) -> Result<(), TracerouteError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_driver_info() { + let info = TracerouteDriverInfo { + supports_parallel: true, + }; + assert!(info.supports_parallel); + } +} diff --git a/rust/crates/traceroute-core/src/types.rs b/rust/crates/traceroute-core/src/types.rs new file mode 100644 index 00000000..46d99221 --- /dev/null +++ b/rust/crates/traceroute-core/src/types.rs @@ -0,0 +1,217 @@ +//! Core types for traceroute operations. + +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; +use std::time::Duration; + +/// Response from a single probe. +#[derive(Debug, Clone)] +pub struct ProbeResponse { + /// The TTL that was used for this probe. + pub ttl: u8, + /// The IP address that responded. + pub ip: IpAddr, + /// Round-trip time for this probe. + pub rtt: Duration, + /// Whether this response came from the destination. + pub is_dest: bool, +} + +/// Parameters for traceroute execution. +#[derive(Debug, Clone)] +pub struct TracerouteParams { + /// Minimum TTL to start with. + pub min_ttl: u8, + /// Maximum TTL to probe. + pub max_ttl: u8, + /// Timeout for each probe. + pub timeout: Duration, + /// How often to poll for responses in parallel mode. + pub poll_frequency: Duration, + /// Delay between sending probes. + pub send_delay: Duration, +} + +impl Default for TracerouteParams { + fn default() -> Self { + Self { + min_ttl: 1, + max_ttl: 30, + timeout: Duration::from_millis(3000), + poll_frequency: Duration::from_millis(100), + send_delay: Duration::from_millis(50), + } + } +} + +impl TracerouteParams { + /// Validates the parameters. + pub fn validate(&self) -> Result<(), crate::TracerouteError> { + if self.min_ttl > self.max_ttl { + return Err(crate::TracerouteError::InvalidTtlRange { + min_ttl: self.min_ttl, + max_ttl: self.max_ttl, + }); + } + Ok(()) + } + + /// Calculate the maximum timeout for parallel execution. + pub fn max_timeout(&self) -> Duration { + let num_ttls = (self.max_ttl - self.min_ttl + 1) as u32; + self.send_delay * num_ttls + self.timeout + } +} + +/// Protocol to use for traceroute. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + #[default] + Udp, + Tcp, + Icmp, +} + +impl std::fmt::Display for Protocol { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Protocol::Udp => write!(f, "udp"), + Protocol::Tcp => write!(f, "tcp"), + Protocol::Icmp => write!(f, "icmp"), + } + } +} + +impl std::str::FromStr for Protocol { + type Err = crate::TracerouteError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "udp" => Ok(Protocol::Udp), + "tcp" => Ok(Protocol::Tcp), + "icmp" => Ok(Protocol::Icmp), + _ => Err(crate::TracerouteError::UnknownProtocol(s.to_string())), + } + } +} + +/// TCP method to use for TCP traceroute. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TcpMethod { + /// Standard TCP SYN traceroute. + #[default] + Syn, + /// TCP SACK-based traceroute. + Sack, + /// Try SACK first, fall back to SYN if not supported. + PreferSack, + /// Use socket options (Windows-specific fallback). + SynSocket, +} + +impl std::fmt::Display for TcpMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TcpMethod::Syn => write!(f, "syn"), + TcpMethod::Sack => write!(f, "sack"), + TcpMethod::PreferSack => write!(f, "prefer_sack"), + TcpMethod::SynSocket => write!(f, "syn_socket"), + } + } +} + +impl std::str::FromStr for TcpMethod { + type Err = crate::TracerouteError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "syn" => Ok(TcpMethod::Syn), + "sack" => Ok(TcpMethod::Sack), + "prefer_sack" => Ok(TcpMethod::PreferSack), + "syn_socket" => Ok(TcpMethod::SynSocket), + _ => Err(crate::TracerouteError::UnknownProtocol(format!( + "unknown TCP method: {}", + s + ))), + } + } +} + +/// High-level traceroute configuration. +#[derive(Debug, Clone)] +pub struct TracerouteConfig { + /// Target hostname or IP address. + pub hostname: String, + /// Destination port. + pub port: u16, + /// Protocol to use. + pub protocol: Protocol, + /// Traceroute parameters. + pub params: TracerouteParams, + /// TCP method (only used when protocol is TCP). + pub tcp_method: TcpMethod, + /// Whether to use IPv6. + pub want_v6: bool, + /// Whether to perform reverse DNS lookups. + pub reverse_dns: bool, + /// Whether to collect source public IP. + pub collect_source_public_ip: bool, + /// Number of traceroute queries to run. + pub traceroute_queries: usize, + /// Number of end-to-end probes to run. + pub e2e_queries: usize, + /// Whether to use the Windows driver (Windows only). + pub use_windows_driver: bool, + /// Whether to skip private hops in output. + pub skip_private_hops: bool, +} + +impl Default for TracerouteConfig { + fn default() -> Self { + Self { + hostname: String::new(), + port: 33434, + protocol: Protocol::Udp, + params: TracerouteParams::default(), + tcp_method: TcpMethod::Syn, + want_v6: false, + reverse_dns: false, + collect_source_public_ip: false, + traceroute_queries: 3, + e2e_queries: 50, + use_windows_driver: false, + skip_private_hops: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_traceroute_params_validate() { + let valid = TracerouteParams { + min_ttl: 1, + max_ttl: 30, + ..Default::default() + }; + assert!(valid.validate().is_ok()); + + let invalid = TracerouteParams { + min_ttl: 30, + max_ttl: 1, + ..Default::default() + }; + assert!(invalid.validate().is_err()); + } + + #[test] + fn test_protocol_from_str() { + assert_eq!("udp".parse::().unwrap(), Protocol::Udp); + assert_eq!("TCP".parse::().unwrap(), Protocol::Tcp); + assert_eq!("ICMP".parse::().unwrap(), Protocol::Icmp); + assert!("invalid".parse::().is_err()); + } +} diff --git a/rust/crates/traceroute-icmp/Cargo.toml b/rust/crates/traceroute-icmp/Cargo.toml new file mode 100644 index 00000000..20665833 --- /dev/null +++ b/rust/crates/traceroute-icmp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "traceroute-icmp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "ICMP traceroute implementation" + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +pnet = { workspace = true } +pnet_packet = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-icmp/src/driver.rs b/rust/crates/traceroute-icmp/src/driver.rs new file mode 100644 index 00000000..1c1c0620 --- /dev/null +++ b/rust/crates/traceroute-icmp/src/driver.rs @@ -0,0 +1,355 @@ +//! ICMP traceroute driver implementation. + +use crate::packet::create_icmp_echo_packet; +use async_trait::async_trait; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU16, Ordering}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use traceroute_core::{ProbeResponse, TracerouteDriver, TracerouteDriverInfo, TracerouteError}; +use traceroute_packets::{FrameParser, Sink, Source}; +use tracing::{debug, trace}; + +/// Global echo ID counter for unique IDs across driver instances. +static ECHO_ID_COUNTER: AtomicU16 = AtomicU16::new(1); + +/// Gets the next unique echo ID. +fn next_echo_id() -> u16 { + ECHO_ID_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// ICMP traceroute driver. +pub struct IcmpDriver { + /// Source IP address. + src_ip: IpAddr, + /// Target IP address. + target_ip: IpAddr, + /// Packet source for receiving. + source: Box, + /// Packet sink for sending. + sink: Box, + /// Read buffer. + buffer: Vec, + /// Frame parser. + parser: FrameParser, + /// Map of TTL to send time for RTT calculation. + sent_probes: Mutex>, + /// Echo ID for this traceroute session. + echo_id: u16, + /// Minimum TTL. + min_ttl: u8, + /// Maximum TTL. + max_ttl: u8, +} + +impl IcmpDriver { + /// Creates a new ICMP driver. + pub fn new( + src_ip: IpAddr, + target_ip: IpAddr, + source: Box, + sink: Box, + min_ttl: u8, + max_ttl: u8, + ) -> Self { + Self { + src_ip, + target_ip, + source, + sink, + buffer: vec![0u8; 1500], + parser: FrameParser::new(), + sent_probes: Mutex::new(HashMap::new()), + echo_id: next_echo_id(), + min_ttl, + max_ttl, + } + } + + fn store_probe(&self, ttl: u8) -> Result<(), TracerouteError> { + let mut probes = self.sent_probes.lock().unwrap(); + + // Refuse to store if we would overwrite + if probes.contains_key(&ttl) { + return Err(TracerouteError::Internal(format!( + "Tried to send the same probe twice for TTL={}", + ttl + ))); + } + + probes.insert(ttl, Instant::now()); + Ok(()) + } + + fn find_matching_probe(&self, ttl: u8) -> Option { + let probes = self.sent_probes.lock().unwrap(); + probes.get(&ttl).copied() + } + + fn get_rtt_from_seq(&self, seq: u8) -> Result { + if seq < self.min_ttl || seq > self.max_ttl { + return Err(TracerouteError::MalformedPacket(format!( + "Invalid sequence number {}", + seq + ))); + } + + match self.find_matching_probe(seq) { + Some(send_time) => Ok(send_time.elapsed()), + None => Err(TracerouteError::MalformedPacket(format!( + "No probe sent for sequence number {}", + seq + ))), + } + } + + async fn handle_probe_layers(&mut self) -> Result, TracerouteError> { + let ip_pair = self.parser.get_ip_pair(); + + // Must be ICMP + if !self.parser.is_icmp() { + return Err(TracerouteError::PacketMismatch); + } + + // Check for Echo Reply (destination reached) + if self.parser.is_echo_reply { + return self.handle_echo_reply(&ip_pair); + } + + // Check for TTL Exceeded (intermediate hop) + if self.parser.is_ttl_exceeded() { + return self.handle_ttl_exceeded(&ip_pair); + } + + Err(TracerouteError::PacketMismatch) + } + + fn handle_echo_reply( + &self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + let icmp_info = self + .parser + .get_icmp_info() + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP info".to_string()))?; + + // For Echo Reply, we need to extract ID and Seq from the ICMP header + // The parser stores this in icmp_info for echo replies + // We'll need to parse the ID/Seq from the payload structure + + // In the Go implementation, parser.ICMP4.Id and parser.ICMP4.Seq are used + // For now, we'll extract from the wrapped_packet_id which holds the echo ID + // and check against our echo_id + + // Actually, for echo reply, the ID and Seq are in the ICMP header itself + // Let's check the payload which should contain our echo response data + + // Simplified: Just check that this reply is from our target + let src_addr = ip_pair.src_addr.ok_or_else(|| { + TracerouteError::MalformedPacket("Missing source address".to_string()) + })?; + + // The sequence number is the TTL we sent + // For echo replies, wrapped_packet_id contains the echo identifier + // and we can extract seq from the payload + + // For now, get the TTL from the ICMP payload + // The payload should start with the echo ID (2 bytes) and seq (2 bytes) + if icmp_info.payload.len() < 4 { + return Err(TracerouteError::MalformedPacket( + "ICMP payload too short".to_string(), + )); + } + + let id = u16::from_be_bytes([icmp_info.payload[0], icmp_info.payload[1]]); + let seq = u16::from_be_bytes([icmp_info.payload[2], icmp_info.payload[3]]); + + if id != self.echo_id { + trace!( + expected = self.echo_id, + actual = id, + "Ignored ICMP Echo Reply with different echo ID" + ); + return Err(TracerouteError::PacketMismatch); + } + + let ttl = seq as u8; + let rtt = self.get_rtt_from_seq(ttl)?; + + Ok(Some(ProbeResponse { + ttl, + ip: src_addr, + rtt, + is_dest: true, + })) + } + + fn handle_ttl_exceeded( + &self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + let icmp_info = self + .parser + .get_icmp_info() + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP info".to_string()))?; + + // Check that the embedded packet was destined for our target + let icmp_dst = icmp_info.icmp_pair.dst_addr.ok_or_else(|| { + TracerouteError::MalformedPacket("Missing ICMP destination".to_string()) + })?; + + if icmp_dst != self.target_ip { + trace!( + expected = %self.target_ip, + actual = %icmp_dst, + "Ignored ICMP TTL Exceeded with different destination" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Check that the embedded packet was from us + let icmp_src = icmp_info + .icmp_pair + .src_addr + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP source".to_string()))?; + + if icmp_src != self.src_ip { + trace!( + expected = %self.src_ip, + actual = %icmp_src, + "Ignored ICMP TTL Exceeded with different source" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Parse the embedded ICMP Echo Request to get ID and Seq + // The payload contains the original ICMP packet + if icmp_info.payload.len() < 8 { + return Err(TracerouteError::MalformedPacket( + "ICMP payload too short for Echo Request".to_string(), + )); + } + + // ICMP Echo Request format: Type(1) + Code(1) + Checksum(2) + ID(2) + Seq(2) + let icmp_type = icmp_info.payload[0]; + if icmp_type != 8 { + // Not an Echo Request + return Err(TracerouteError::PacketMismatch); + } + + let id = u16::from_be_bytes([icmp_info.payload[4], icmp_info.payload[5]]); + let seq = u16::from_be_bytes([icmp_info.payload[6], icmp_info.payload[7]]); + + if id != self.echo_id { + trace!( + expected = self.echo_id, + actual = id, + "Ignored ICMP TTL Exceeded with different echo ID" + ); + return Err(TracerouteError::PacketMismatch); + } + + let ttl = seq as u8; + let rtt = self.get_rtt_from_seq(ttl)?; + + let src_addr = ip_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + + Ok(Some(ProbeResponse { + ttl, + ip: src_addr, + rtt, + is_dest: false, + })) + } +} + +#[async_trait] +impl TracerouteDriver for IcmpDriver { + fn get_driver_info(&self) -> TracerouteDriverInfo { + TracerouteDriverInfo { + // ICMP supports parallel mode because each probe has a unique + // sequence number that identifies which TTL it was for + supports_parallel: true, + } + } + + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError> { + if ttl < self.min_ttl || ttl > self.max_ttl { + return Err(TracerouteError::Internal(format!( + "Asked to send invalid TTL {}", + ttl + ))); + } + + self.store_probe(ttl)?; + + let packet = create_icmp_echo_packet(self.src_ip, self.target_ip, ttl, self.echo_id)?; + + trace!( + ttl = ttl, + echo_id = self.echo_id, + "Sending ICMP Echo Request probe" + ); + + // ICMP doesn't have a port, but we need to provide a SocketAddr + // Use port 0 as a placeholder + let target_addr = SocketAddr::new(self.target_ip, 0); + self.sink.write_to(&packet, target_addr).await?; + + Ok(()) + } + + async fn receive_probe( + &mut self, + timeout: Duration, + ) -> Result, TracerouteError> { + let deadline = Instant::now() + timeout; + self.source.set_read_deadline(deadline)?; + + let n = self.source.read(&mut self.buffer).await?; + + if let Err(e) = self.parser.parse(&self.buffer[..n]) { + debug!(error = %e, "Failed to parse packet"); + return Err(e); + } + + self.handle_probe_layers().await + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + let sink_result = self.sink.close().await; + let source_result = self.source.close().await; + + sink_result?; + source_result?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_driver_info() { + let info = TracerouteDriverInfo { + supports_parallel: true, + }; + assert!(info.supports_parallel); + } + + #[test] + fn test_echo_id_uniqueness() { + let id1 = next_echo_id(); + let id2 = next_echo_id(); + let id3 = next_echo_id(); + + assert_ne!(id1, id2); + assert_ne!(id2, id3); + assert_ne!(id1, id3); + } +} diff --git a/rust/crates/traceroute-icmp/src/lib.rs b/rust/crates/traceroute-icmp/src/lib.rs new file mode 100644 index 00000000..ec504ac8 --- /dev/null +++ b/rust/crates/traceroute-icmp/src/lib.rs @@ -0,0 +1,7 @@ +//! ICMP traceroute implementation. + +mod driver; +mod packet; + +pub use driver::IcmpDriver; +pub use packet::create_icmp_echo_packet; diff --git a/rust/crates/traceroute-icmp/src/packet.rs b/rust/crates/traceroute-icmp/src/packet.rs new file mode 100644 index 00000000..e8de949e --- /dev/null +++ b/rust/crates/traceroute-icmp/src/packet.rs @@ -0,0 +1,142 @@ +//! ICMP packet construction using pnet. + +use pnet_packet::icmp::echo_request::MutableEchoRequestPacket; +use pnet_packet::icmp::{IcmpCode, IcmpPacket, IcmpTypes}; +use pnet_packet::ip::IpNextHeaderProtocols; +use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet}; +use std::net::{IpAddr, Ipv4Addr}; +use traceroute_core::TracerouteError; + +/// Creates an ICMP Echo Request packet for traceroute. +/// +/// Returns the packet bytes. +pub fn create_icmp_echo_packet( + src_ip: IpAddr, + dst_ip: IpAddr, + ttl: u8, + echo_id: u16, +) -> Result, TracerouteError> { + match (src_ip, dst_ip) { + (IpAddr::V4(src), IpAddr::V4(dst)) => create_icmp_echo_packet_v4(src, dst, ttl, echo_id), + (IpAddr::V6(_src), IpAddr::V6(_dst)) => { + // TODO: Implement IPv6 + Err(TracerouteError::Internal( + "IPv6 not yet implemented".to_string(), + )) + } + _ => Err(TracerouteError::Internal( + "IP version mismatch between source and destination".to_string(), + )), + } +} + +fn create_icmp_echo_packet_v4( + src_ip: Ipv4Addr, + dst_ip: Ipv4Addr, + ttl: u8, + echo_id: u16, +) -> Result, TracerouteError> { + // ICMP Echo Request: 8 bytes header + 1 byte payload (ttl) + let icmp_len = 8 + 1; + let ip_len = 20 + icmp_len; + + let mut buffer = vec![0u8; ip_len]; + + // Create IPv4 packet + let mut ip_packet = MutableIpv4Packet::new(&mut buffer) + .ok_or_else(|| TracerouteError::Internal("Failed to create IP packet".to_string()))?; + + ip_packet.set_version(4); + ip_packet.set_header_length(5); + ip_packet.set_total_length(ip_len as u16); + ip_packet.set_identification(echo_id); + ip_packet.set_flags(Ipv4Flags::DontFragment); + ip_packet.set_ttl(ttl); + ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Icmp); + ip_packet.set_source(src_ip); + ip_packet.set_destination(dst_ip); + + // Calculate IP checksum + let ip_checksum = pnet_packet::ipv4::checksum(&ip_packet.to_immutable()); + ip_packet.set_checksum(ip_checksum); + + // Create ICMP Echo Request in the payload section + let icmp_start = 20; + { + let mut icmp_packet = MutableEchoRequestPacket::new(&mut buffer[icmp_start..]) + .ok_or_else(|| TracerouteError::Internal("Failed to create ICMP packet".to_string()))?; + + icmp_packet.set_icmp_type(IcmpTypes::EchoRequest); + icmp_packet.set_icmp_code(IcmpCode::new(0)); + icmp_packet.set_identifier(echo_id); + // Sequence number is the TTL - this is how we identify which probe got a response + icmp_packet.set_sequence_number(ttl as u16); + // Payload is just the TTL byte + icmp_packet.set_payload(&[ttl]); + } + + // Calculate ICMP checksum using IcmpPacket view + { + let icmp_view = IcmpPacket::new(&buffer[icmp_start..]) + .ok_or_else(|| TracerouteError::Internal("Failed to create ICMP view".to_string()))?; + let icmp_checksum = pnet_packet::icmp::checksum(&icmp_view); + buffer[icmp_start + 2] = (icmp_checksum >> 8) as u8; + buffer[icmp_start + 3] = (icmp_checksum & 0xff) as u8; + } + + Ok(buffer) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_icmp_echo_packet() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + let result = create_icmp_echo_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), 5, 12345); + + assert!(result.is_ok()); + let packet = result.unwrap(); + + // Check packet length (20 IP + 8 ICMP header + 1 payload = 29) + assert_eq!(packet.len(), 29); + + // Check IP version + assert_eq!(packet[0] >> 4, 4); + + // Check TTL + assert_eq!(packet[8], 5); + + // Check protocol (ICMP = 1) + assert_eq!(packet[9], 1); + + // Check ICMP type (Echo Request = 8) + assert_eq!(packet[20], 8); + + // Check ICMP code (0) + assert_eq!(packet[21], 0); + } + + #[test] + fn test_echo_id_and_seq() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + let echo_id: u16 = 0xABCD; + let ttl: u8 = 10; + + let packet = + create_icmp_echo_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), ttl, echo_id).unwrap(); + + // Echo ID is at ICMP offset + 4 (bytes 24-25) + let icmp_start = 20; + let parsed_id = u16::from_be_bytes([packet[icmp_start + 4], packet[icmp_start + 5]]); + assert_eq!(parsed_id, echo_id); + + // Sequence number is at ICMP offset + 6 (bytes 26-27) + let parsed_seq = u16::from_be_bytes([packet[icmp_start + 6], packet[icmp_start + 7]]); + assert_eq!(parsed_seq, ttl as u16); + } +} diff --git a/rust/crates/traceroute-packets/Cargo.toml b/rust/crates/traceroute-packets/Cargo.toml new file mode 100644 index 00000000..ab2a67ea --- /dev/null +++ b/rust/crates/traceroute-packets/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "traceroute-packets" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Packet I/O abstraction for datadog-traceroute" + +[features] +default = [] +driver = [] # Windows driver support (FFI bindings) + +[dependencies] +traceroute-core = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +etherparse = { workspace = true } +socket2 = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +bytes = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-packets/src/lib.rs b/rust/crates/traceroute-packets/src/lib.rs new file mode 100644 index 00000000..c9cabe92 --- /dev/null +++ b/rust/crates/traceroute-packets/src/lib.rs @@ -0,0 +1,32 @@ +//! Packet I/O abstraction for datadog-traceroute. +//! +//! Provides platform-agnostic interfaces for sending and receiving raw packets. + +pub mod parser; +pub mod platform; +pub mod sink; +pub mod source; + +pub use parser::{ + parse_tcp_first_bytes, parse_udp_first_bytes, FrameParser, IcmpInfo, IpPair, TcpInfo, UdpInfo, +}; +pub use sink::Sink; +pub use source::{FilterType, PacketFilterSpec, Source}; + +/// Handle containing both source and sink for packet I/O. +pub struct SourceSinkHandle { + /// Packet capture source. + pub source: Box, + /// Packet transmission sink. + pub sink: Box, + /// Whether the port must be closed before receiving (Windows-specific). + pub must_close_port: bool, +} + +/// Creates a Source and Sink appropriate for the current platform. +pub async fn new_source_sink( + target_addr: std::net::IpAddr, + use_driver: bool, +) -> Result { + platform::new_source_sink(target_addr, use_driver).await +} diff --git a/rust/crates/traceroute-packets/src/parser.rs b/rust/crates/traceroute-packets/src/parser.rs new file mode 100644 index 00000000..50718f17 --- /dev/null +++ b/rust/crates/traceroute-packets/src/parser.rs @@ -0,0 +1,463 @@ +//! Frame parsing using etherparse. + +use etherparse::{ + Icmpv4Header, Icmpv4Type, Icmpv6Header, Icmpv6Type, NetHeaders, PacketHeaders, TransportHeader, +}; +use std::net::IpAddr; +use traceroute_core::TracerouteError; + +/// Parsed ICMP information from a packet. +#[derive(Debug, Clone)] +pub struct IcmpInfo { + /// ICMP type. + pub icmp_type: u8, + /// ICMP code. + pub icmp_code: u8, + /// Source/dest IPs from outer IP header. + pub ip_pair: IpPair, + /// Wrapped packet ID (from inner IP header if TTL exceeded). + pub wrapped_packet_id: u16, + /// Source/dest IPs from the wrapped IP payload. + pub icmp_pair: IpPair, + /// Payload from within the wrapped IP packet (first 8 bytes of TCP/UDP). + pub payload: Vec, +} + +/// IP source/destination pair. +#[derive(Debug, Clone, Copy, Default)] +pub struct IpPair { + /// Source IP address. + pub src_addr: Option, + /// Destination IP address. + pub dst_addr: Option, +} + +impl IpPair { + /// Returns the pair with source and destination swapped. + pub fn flipped(&self) -> Self { + Self { + src_addr: self.dst_addr, + dst_addr: self.src_addr, + } + } +} + +/// UDP header info parsed from ICMP payload. +#[derive(Debug, Clone, Copy)] +pub struct UdpInfo { + /// Source port. + pub src_port: u16, + /// Destination port. + pub dst_port: u16, + /// UDP length. + pub length: u16, + /// UDP checksum. + pub checksum: u16, +} + +/// TCP header info parsed from ICMP payload. +#[derive(Debug, Clone, Copy)] +pub struct TcpInfo { + /// Source port. + pub src_port: u16, + /// Destination port. + pub dst_port: u16, + /// Sequence number. + pub seq: u32, +} + +/// Parse the first 8 bytes of a UDP header from an ICMP payload. +pub fn parse_udp_first_bytes(buf: &[u8]) -> Result { + if buf.len() < 8 { + return Err(TracerouteError::PacketTooShort { + expected: 8, + actual: buf.len(), + }); + } + + Ok(UdpInfo { + src_port: u16::from_be_bytes([buf[0], buf[1]]), + dst_port: u16::from_be_bytes([buf[2], buf[3]]), + length: u16::from_be_bytes([buf[4], buf[5]]), + checksum: u16::from_be_bytes([buf[6], buf[7]]), + }) +} + +/// Parse the first 8 bytes of a TCP header from an ICMP payload. +pub fn parse_tcp_first_bytes(buf: &[u8]) -> Result { + if buf.len() < 8 { + return Err(TracerouteError::PacketTooShort { + expected: 8, + actual: buf.len(), + }); + } + + Ok(TcpInfo { + src_port: u16::from_be_bytes([buf[0], buf[1]]), + dst_port: u16::from_be_bytes([buf[2], buf[3]]), + seq: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]), + }) +} + +/// Frame parser for network packets. +#[derive(Debug, Default)] +pub struct FrameParser { + /// Last parsed IP source address. + pub src_ip: Option, + /// Last parsed IP destination address. + pub dst_ip: Option, + /// Whether the last packet was a TTL exceeded response. + pub is_ttl_exceeded: bool, + /// Whether the last packet was a destination unreachable response. + pub is_dest_unreachable: bool, + /// Whether the last packet was an ICMP Echo Reply. + pub is_echo_reply: bool, + /// Whether the last packet was a TCP SYN/ACK. + pub is_syn_ack: bool, + /// Whether the last packet was a TCP RST. + pub is_rst: bool, + /// ICMP info if packet was ICMP. + pub icmp_info: Option, + /// TCP info if packet was TCP. + pub tcp_info: Option, + /// Transport layer type. + transport_type: TransportType, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +enum TransportType { + #[default] + None, + Tcp, + Udp, + Icmpv4, + Icmpv6, +} + +impl FrameParser { + /// Creates a new frame parser. + pub fn new() -> Self { + Self::default() + } + + /// Resets the parser state. + fn reset(&mut self) { + self.src_ip = None; + self.dst_ip = None; + self.is_ttl_exceeded = false; + self.is_dest_unreachable = false; + self.is_echo_reply = false; + self.is_syn_ack = false; + self.is_rst = false; + self.icmp_info = None; + self.tcp_info = None; + self.transport_type = TransportType::None; + } + + /// Parses a raw packet buffer (starting from IP layer). + pub fn parse(&mut self, data: &[u8]) -> Result<(), TracerouteError> { + self.reset(); + + let headers = + PacketHeaders::from_ip_slice(data).map_err(|e| TracerouteError::PacketParseFailed { + layer: "IP", + reason: e.to_string(), + })?; + + // Extract IP addresses + let ip_pair = match &headers.net { + Some(NetHeaders::Ipv4(ipv4, _)) => { + self.src_ip = Some(IpAddr::V4(ipv4.source.into())); + self.dst_ip = Some(IpAddr::V4(ipv4.destination.into())); + IpPair { + src_addr: self.src_ip, + dst_addr: self.dst_ip, + } + } + Some(NetHeaders::Ipv6(ipv6, _)) => { + self.src_ip = Some(IpAddr::V6(ipv6.source.into())); + self.dst_ip = Some(IpAddr::V6(ipv6.destination.into())); + IpPair { + src_addr: self.src_ip, + dst_addr: self.dst_ip, + } + } + None => { + return Err(TracerouteError::PacketParseFailed { + layer: "IP", + reason: "No IP header found".to_string(), + }); + } + }; + + // Parse transport layer + let payload_slice = headers.payload.slice(); + match headers.transport { + Some(TransportHeader::Icmpv4(icmp)) => { + self.transport_type = TransportType::Icmpv4; + self.parse_icmpv4(&icmp, payload_slice, ip_pair)?; + } + Some(TransportHeader::Icmpv6(icmp)) => { + self.transport_type = TransportType::Icmpv6; + self.parse_icmpv6(&icmp, payload_slice, ip_pair)?; + } + Some(TransportHeader::Tcp(tcp)) => { + self.transport_type = TransportType::Tcp; + self.is_syn_ack = tcp.syn && tcp.ack; + self.is_rst = tcp.rst; + self.tcp_info = Some(TcpInfo { + src_port: tcp.source_port, + dst_port: tcp.destination_port, + seq: tcp.sequence_number, + }); + } + Some(TransportHeader::Udp(_)) => { + self.transport_type = TransportType::Udp; + } + None => { + return Err(TracerouteError::PacketParseFailed { + layer: "Transport", + reason: "No transport header found".to_string(), + }); + } + } + + Ok(()) + } + + fn parse_icmpv4( + &mut self, + icmp: &Icmpv4Header, + payload: &[u8], + ip_pair: IpPair, + ) -> Result<(), TracerouteError> { + let (icmp_type, icmp_code) = match &icmp.icmp_type { + Icmpv4Type::TimeExceeded(code) => { + self.is_ttl_exceeded = true; + (11, code.code_u8()) + } + Icmpv4Type::DestinationUnreachable(header) => { + self.is_dest_unreachable = true; + (3, header.code_u8()) + } + Icmpv4Type::EchoReply(echo) => { + self.is_echo_reply = true; + // Store echo ID and sequence in payload for driver to read + let id_bytes = echo.id.to_be_bytes(); + let seq_bytes = echo.seq.to_be_bytes(); + self.icmp_info = Some(IcmpInfo { + icmp_type: 0, + icmp_code: 0, + ip_pair, + wrapped_packet_id: echo.id, + icmp_pair: IpPair::default(), + payload: vec![id_bytes[0], id_bytes[1], seq_bytes[0], seq_bytes[1]], + }); + return Ok(()); + } + Icmpv4Type::EchoRequest(_) => (8, 0), + Icmpv4Type::Unknown { + type_u8, code_u8, .. + } => (*type_u8, *code_u8), + _ => (0, 0), // Default for other known types we don't handle + }; + + // For TTL exceeded and dest unreachable, parse the inner IP packet + if self.is_ttl_exceeded || self.is_dest_unreachable { + self.icmp_info = Some(self.parse_icmp_payload(payload, ip_pair, icmp_type, icmp_code)?); + } else { + self.icmp_info = Some(IcmpInfo { + icmp_type, + icmp_code, + ip_pair, + wrapped_packet_id: 0, + icmp_pair: IpPair::default(), + payload: Vec::new(), + }); + } + + Ok(()) + } + + fn parse_icmpv6( + &mut self, + icmp: &Icmpv6Header, + payload: &[u8], + ip_pair: IpPair, + ) -> Result<(), TracerouteError> { + let (icmp_type, icmp_code) = match &icmp.icmp_type { + Icmpv6Type::TimeExceeded(code) => { + self.is_ttl_exceeded = true; + (3, code.code_u8()) + } + Icmpv6Type::DestinationUnreachable(code) => { + self.is_dest_unreachable = true; + (1, code.code_u8()) + } + Icmpv6Type::EchoReply(echo) => { + self.is_echo_reply = true; + // Store echo ID and sequence in payload for driver to read + let id_bytes = echo.id.to_be_bytes(); + let seq_bytes = echo.seq.to_be_bytes(); + self.icmp_info = Some(IcmpInfo { + icmp_type: 129, + icmp_code: 0, + ip_pair, + wrapped_packet_id: echo.id, + icmp_pair: IpPair::default(), + payload: vec![id_bytes[0], id_bytes[1], seq_bytes[0], seq_bytes[1]], + }); + return Ok(()); + } + Icmpv6Type::EchoRequest(_) => (128, 0), + Icmpv6Type::Unknown { + type_u8, code_u8, .. + } => (*type_u8, *code_u8), + _ => (0, 0), // Default for other known types we don't handle + }; + + if self.is_ttl_exceeded || self.is_dest_unreachable { + // ICMPv6 has a 4-byte unused field before the embedded packet + let inner_payload = if payload.len() > 4 { + &payload[4..] + } else { + payload + }; + self.icmp_info = + Some(self.parse_icmp_payload(inner_payload, ip_pair, icmp_type, icmp_code)?); + } else { + self.icmp_info = Some(IcmpInfo { + icmp_type, + icmp_code, + ip_pair, + wrapped_packet_id: 0, + icmp_pair: IpPair::default(), + payload: Vec::new(), + }); + } + + Ok(()) + } + + fn parse_icmp_payload( + &self, + payload: &[u8], + ip_pair: IpPair, + icmp_type: u8, + icmp_code: u8, + ) -> Result { + // Parse the embedded IP packet + let inner_headers = PacketHeaders::from_ip_slice(payload).map_err(|e| { + TracerouteError::PacketParseFailed { + layer: "Inner IP", + reason: e.to_string(), + } + })?; + + let (wrapped_packet_id, icmp_pair) = match &inner_headers.net { + Some(NetHeaders::Ipv4(ipv4, _)) => { + let pair = IpPair { + src_addr: Some(IpAddr::V4(ipv4.source.into())), + dst_addr: Some(IpAddr::V4(ipv4.destination.into())), + }; + (ipv4.identification, pair) + } + Some(NetHeaders::Ipv6(ipv6, _)) => { + let pair = IpPair { + src_addr: Some(IpAddr::V6(ipv6.source.into())), + dst_addr: Some(IpAddr::V6(ipv6.destination.into())), + }; + // For IPv6 UDP, use payload length as packet ID + (ipv6.payload_length, pair) + } + None => (0, IpPair::default()), + }; + + Ok(IcmpInfo { + icmp_type, + icmp_code, + ip_pair, + wrapped_packet_id, + icmp_pair, + payload: inner_headers.payload.slice().to_vec(), + }) + } + + /// Returns true if the parsed packet was a TTL exceeded response. + pub fn is_ttl_exceeded(&self) -> bool { + self.is_ttl_exceeded + } + + /// Returns true if the parsed packet was a destination unreachable response. + pub fn is_dest_unreachable(&self) -> bool { + self.is_dest_unreachable + } + + /// Returns the ICMP info if available. + pub fn get_icmp_info(&self) -> Option<&IcmpInfo> { + self.icmp_info.as_ref() + } + + /// Returns the IP pair from the outer packet. + pub fn get_ip_pair(&self) -> IpPair { + IpPair { + src_addr: self.src_ip, + dst_addr: self.dst_ip, + } + } + + /// Returns true if the transport layer is ICMP. + pub fn is_icmp(&self) -> bool { + matches!( + self.transport_type, + TransportType::Icmpv4 | TransportType::Icmpv6 + ) + } + + /// Returns true if the transport layer is TCP. + pub fn is_tcp(&self) -> bool { + self.transport_type == TransportType::Tcp + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parser_creation() { + let parser = FrameParser::new(); + assert!(parser.src_ip.is_none()); + assert!(parser.dst_ip.is_none()); + } + + #[test] + fn test_parse_udp_first_bytes() { + let buf = [0x00, 0x50, 0x82, 0x9A, 0x00, 0x10, 0x12, 0x34]; + let info = parse_udp_first_bytes(&buf).unwrap(); + assert_eq!(info.src_port, 80); + assert_eq!(info.dst_port, 33434); + assert_eq!(info.length, 16); + assert_eq!(info.checksum, 0x1234); + } + + #[test] + fn test_parse_tcp_first_bytes() { + let buf = [0x00, 0x50, 0x01, 0xBB, 0x12, 0x34, 0x56, 0x78]; + let info = parse_tcp_first_bytes(&buf).unwrap(); + assert_eq!(info.src_port, 80); + assert_eq!(info.dst_port, 443); + assert_eq!(info.seq, 0x12345678); + } + + #[test] + fn test_ip_pair_flipped() { + let pair = IpPair { + src_addr: Some("10.0.0.1".parse().unwrap()), + dst_addr: Some("10.0.0.2".parse().unwrap()), + }; + let flipped = pair.flipped(); + assert_eq!(flipped.src_addr, pair.dst_addr); + assert_eq!(flipped.dst_addr, pair.src_addr); + } +} diff --git a/rust/crates/traceroute-packets/src/platform/darwin.rs b/rust/crates/traceroute-packets/src/platform/darwin.rs new file mode 100644 index 00000000..584efc7b --- /dev/null +++ b/rust/crates/traceroute-packets/src/platform/darwin.rs @@ -0,0 +1,528 @@ +//! macOS-specific packet I/O using BPF devices. + +use crate::{PacketFilterSpec, Sink, Source, SourceSinkHandle}; +use async_trait::async_trait; +use std::net::{IpAddr, SocketAddr, UdpSocket}; +use std::os::fd::{AsRawFd, RawFd}; +use std::time::{Duration, Instant}; +use traceroute_core::TracerouteError; +use tracing::{debug, trace}; + +/// Maximum number of BPF devices on macOS. +const MAX_BPF_DEVICES: usize = 256; + +/// BPF header alignment (4 bytes on macOS, even on 64-bit systems). +const BPF_ALIGNMENT: usize = 4; + +/// Size of the BPF header structure. +const BPF_HEADER_SIZE: usize = 18; // sizeof(struct bpf_hdr) on macOS + +/// Ethernet header size. +const ETHERNET_HEADER_SIZE: usize = 14; + +/// DLT_NULL header size (loopback). +const DLT_NULL_HEADER_SIZE: usize = 4; + +fn bpf_align(x: usize) -> usize { + (x + BPF_ALIGNMENT - 1) & !(BPF_ALIGNMENT - 1) +} + +/// Finds and opens an available BPF device. +fn pick_bpf_device() -> Result { + for i in 0..MAX_BPF_DEVICES { + let path = format!("/dev/bpf{}", i); + match std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + { + Ok(file) => { + let fd = file.as_raw_fd(); + // Prevent the file from being closed when `file` is dropped + std::mem::forget(file); + return Ok(fd); + } + Err(e) if e.raw_os_error() == Some(libc::EBUSY) => continue, + Err(e) => { + return Err(TracerouteError::Internal(format!( + "Failed to open {}: {}", + path, e + ))); + } + } + } + + Err(TracerouteError::Internal(format!( + "All {} BPF devices are busy", + MAX_BPF_DEVICES + ))) +} + +/// Finds the network interface for the given target IP. +fn device_for_target(target_ip: IpAddr) -> Result<(String, bool), TracerouteError> { + // Check if target is loopback + let is_loopback = match target_ip { + IpAddr::V4(ip) => ip.is_loopback(), + IpAddr::V6(ip) => ip.is_loopback(), + }; + + if is_loopback { + // Return loopback interface + return Ok(("lo0".to_string(), true)); + } + + // Use a UDP socket to determine the outgoing interface + let socket = UdpSocket::bind("0.0.0.0:0") + .map_err(|e| TracerouteError::Internal(format!("Failed to bind UDP socket: {}", e)))?; + + let target_addr = match target_ip { + IpAddr::V4(ip) => SocketAddr::new(IpAddr::V4(ip), 53), + IpAddr::V6(ip) => SocketAddr::new(IpAddr::V6(ip), 53), + }; + + socket + .connect(target_addr) + .map_err(|e| TracerouteError::Internal(format!("Failed to connect UDP socket: {}", e)))?; + + let local_addr = socket + .local_addr() + .map_err(|e| TracerouteError::Internal(format!("Failed to get local address: {}", e)))?; + + // Find interface with matching IP + // For simplicity, we'll use "en0" as the default interface on macOS + // A full implementation would enumerate interfaces and find the matching one + + trace!(local_addr = %local_addr, "Determined local address for target"); + + // Default to en0 for non-loopback + Ok(("en0".to_string(), false)) +} + +/// Strips the Ethernet header and returns the IP payload. +fn strip_ethernet_header(frame: &[u8]) -> Result<&[u8], TracerouteError> { + if frame.len() < ETHERNET_HEADER_SIZE { + return Err(TracerouteError::MalformedPacket(format!( + "Frame too short for Ethernet header: {} bytes", + frame.len() + ))); + } + + // Check EtherType (bytes 12-13) + let ethertype = u16::from_be_bytes([frame[12], frame[13]]); + + match ethertype { + 0x0800 => Ok(&frame[ETHERNET_HEADER_SIZE..]), // IPv4 + 0x86DD => Ok(&frame[ETHERNET_HEADER_SIZE..]), // IPv6 + _ => Err(TracerouteError::MalformedPacket(format!( + "Unsupported EtherType: 0x{:04x}", + ethertype + ))), + } +} + +/// BPF device-based packet source for macOS. +pub struct BpfDevice { + fd: RawFd, + deadline: Option, + read_buf: Vec, + pkt_buf: Vec, + pkt_offset: usize, + is_loopback: bool, +} + +impl BpfDevice { + /// Creates a new BPF device source. + pub fn new(target_ip: IpAddr) -> Result { + let (iface_name, is_loopback) = device_for_target(target_ip)?; + + let fd = pick_bpf_device()?; + + // Set BIOCIMMEDIATE for immediate delivery + let immediate: libc::c_int = 1; + let result = + unsafe { libc::ioctl(fd, libc::BIOCIMMEDIATE, &immediate as *const libc::c_int) }; + if result < 0 { + unsafe { libc::close(fd) }; + return Err(TracerouteError::Internal(format!( + "Failed to set BIOCIMMEDIATE: {}", + std::io::Error::last_os_error() + ))); + } + + // Bind to the interface + let mut ifreq: libc::ifreq = unsafe { std::mem::zeroed() }; + let name_bytes = iface_name.as_bytes(); + let copy_len = std::cmp::min(name_bytes.len(), ifreq.ifr_name.len() - 1); + for (i, &b) in name_bytes[..copy_len].iter().enumerate() { + ifreq.ifr_name[i] = b as i8; + } + + let result = unsafe { libc::ioctl(fd, libc::BIOCSETIF, &ifreq as *const libc::ifreq) }; + if result < 0 { + unsafe { libc::close(fd) }; + return Err(TracerouteError::Internal(format!( + "Failed to bind BPF to interface {}: {}", + iface_name, + std::io::Error::last_os_error() + ))); + } + + debug!(interface = %iface_name, is_loopback = is_loopback, "Opened BPF device"); + + Ok(Self { + fd, + deadline: None, + read_buf: vec![0u8; 4096], + pkt_buf: Vec::new(), + pkt_offset: 0, + is_loopback, + }) + } + + fn has_next_packet(&self) -> bool { + self.pkt_offset < self.pkt_buf.len() + } + + fn read_packets(&mut self) -> Result<(), TracerouteError> { + // Set timeout based on deadline + let timeout = if let Some(deadline) = self.deadline { + let now = Instant::now(); + if now >= deadline { + return Err(TracerouteError::ReadTimeout); + } + deadline.duration_since(now) + } else { + Duration::from_secs(1) + }; + + // Set BPF timeout + let tv = libc::timeval { + tv_sec: timeout.as_secs() as libc::time_t, + tv_usec: timeout.subsec_micros() as libc::suseconds_t, + }; + let result = + unsafe { libc::ioctl(self.fd, libc::BIOCSRTIMEOUT, &tv as *const libc::timeval) }; + if result < 0 { + return Err(TracerouteError::Internal(format!( + "Failed to set BPF timeout: {}", + std::io::Error::last_os_error() + ))); + } + + // Read from BPF device + let n = unsafe { + libc::read( + self.fd, + self.read_buf.as_mut_ptr() as *mut libc::c_void, + self.read_buf.len(), + ) + }; + + if n < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + return Err(TracerouteError::ReadTimeout); + } + return Err(TracerouteError::from(err)); + } + + if n == 0 { + return Err(TracerouteError::ReadTimeout); + } + + self.pkt_buf = self.read_buf[..n as usize].to_vec(); + self.pkt_offset = 0; + + Ok(()) + } + + fn next_packet(&mut self) -> Result<&[u8], TracerouteError> { + if self.pkt_offset + BPF_HEADER_SIZE > self.pkt_buf.len() { + return Err(TracerouteError::MalformedPacket( + "Buffer too small for BPF header".to_string(), + )); + } + + // Parse BPF header + // struct bpf_hdr { + // struct timeval bh_tstamp; // 16 bytes on 64-bit macOS + // uint32_t bh_caplen; + // uint32_t bh_datalen; + // uint16_t bh_hdrlen; + // } + // Actually on macOS: timestamp(8) + caplen(4) + datalen(4) + hdrlen(2) = 18 bytes + + let hdr_start = self.pkt_offset; + + // Read hdrlen (at offset 16, 2 bytes) + let hdrlen = + u16::from_ne_bytes([self.pkt_buf[hdr_start + 16], self.pkt_buf[hdr_start + 17]]) + as usize; + + // Read caplen (at offset 8, 4 bytes) + let caplen = u32::from_ne_bytes([ + self.pkt_buf[hdr_start + 8], + self.pkt_buf[hdr_start + 9], + self.pkt_buf[hdr_start + 10], + self.pkt_buf[hdr_start + 11], + ]) as usize; + + let pkt_start = hdr_start + hdrlen; + let pkt_end = pkt_start + caplen; + let next_offset = bpf_align(pkt_end); + + if pkt_end > self.pkt_buf.len() { + return Err(TracerouteError::MalformedPacket(format!( + "Packet extends beyond buffer: {} > {}", + pkt_end, + self.pkt_buf.len() + ))); + } + + self.pkt_offset = next_offset; + + Ok(&self.pkt_buf[pkt_start..pkt_end]) + } +} + +#[async_trait] +impl Source for BpfDevice { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + self.deadline = Some(deadline); + Ok(()) + } + + async fn read(&mut self, buf: &mut [u8]) -> Result { + let is_loopback = self.is_loopback; + + if !self.has_next_packet() { + self.read_packets()?; + } + + let link_frame = self.next_packet()?; + + // Strip link-layer header to get IP payload + let payload = if is_loopback { + // DLT_NULL: 4-byte address family header + if link_frame.len() < DLT_NULL_HEADER_SIZE { + return Err(TracerouteError::MalformedPacket(format!( + "Loopback packet too short: {} bytes", + link_frame.len() + ))); + } + &link_frame[DLT_NULL_HEADER_SIZE..] + } else { + // DLT_EN10MB: Ethernet header + strip_ethernet_header(link_frame)? + }; + + let copy_len = std::cmp::min(payload.len(), buf.len()); + buf[..copy_len].copy_from_slice(&payload[..copy_len]); + Ok(copy_len) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + if self.fd > 0 { + unsafe { libc::close(self.fd) }; + self.fd = -1; + } + Ok(()) + } + + fn set_packet_filter(&mut self, _spec: PacketFilterSpec) -> Result<(), TracerouteError> { + // BPF filter not implemented for macOS - no-op + Ok(()) + } +} + +/// Raw socket-based packet sink for macOS. +pub struct RawSink { + fd: RawFd, + is_ipv6: bool, + write_buf: Vec, +} + +impl RawSink { + /// Creates a new raw socket sink. + pub fn new(target_addr: IpAddr) -> Result { + let is_ipv6 = target_addr.is_ipv6(); + + let (domain, protocol) = if is_ipv6 { + (libc::AF_INET6, libc::IPPROTO_RAW) + } else { + (libc::AF_INET, libc::IPPROTO_RAW) + }; + + let fd = unsafe { libc::socket(domain, libc::SOCK_RAW, protocol) }; + if fd < 0 { + return Err(TracerouteError::SocketCreation( + std::io::Error::last_os_error(), + )); + } + + // Set IP_HDRINCL for IPv4 (macOS only supports this for IPv4) + if !is_ipv6 { + let hdrincl: libc::c_int = 1; + let result = unsafe { + libc::setsockopt( + fd, + libc::IPPROTO_IP, + libc::IP_HDRINCL, + &hdrincl as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if result < 0 { + unsafe { libc::close(fd) }; + return Err(TracerouteError::Internal(format!( + "Failed to set IP_HDRINCL: {}", + std::io::Error::last_os_error() + ))); + } + } + + Ok(Self { + fd, + is_ipv6, + write_buf: vec![0u8; 4096], + }) + } +} + +#[async_trait] +impl Sink for RawSink { + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError> { + if buf.len() > self.write_buf.len() { + return Err(TracerouteError::Internal(format!( + "Packet too large: {} bytes", + buf.len() + ))); + } + + // Handle IPv4 and IPv6 separately to avoid use-after-free issues with sockaddr pointers. + // The sockaddr structure must remain in scope for the duration of the sendto() call. + if self.is_ipv6 { + // IPv6: macOS has no IPV6_HDRINCL, so strip IPv6 header and set hop limit via socket option + if buf.len() < 40 { + return Err(TracerouteError::MalformedPacket( + "Packet too short for IPv6 header".to_string(), + )); + } + + // Extract TTL (hop limit) from IPv6 header (byte 7) + let ttl = buf[7] as libc::c_int; + + // Set hop limit via IPV6_UNICAST_HOPS + let result = unsafe { + libc::setsockopt( + self.fd, + libc::IPPROTO_IPV6, + libc::IPV6_UNICAST_HOPS, + &ttl as *const libc::c_int as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if result < 0 { + return Err(TracerouteError::Internal(format!( + "Failed to set IPV6_UNICAST_HOPS: {}", + std::io::Error::last_os_error() + ))); + } + + // Use payload without IPv6 header + let payload = &buf[40..]; + + let sa6 = libc::sockaddr_in6 { + sin6_len: std::mem::size_of::() as u8, + sin6_family: libc::AF_INET6 as u8, + sin6_port: 0, + sin6_flowinfo: 0, + sin6_addr: match addr.ip() { + IpAddr::V6(ip) => libc::in6_addr { + s6_addr: ip.octets(), + }, + _ => unreachable!(), + }, + sin6_scope_id: 0, + }; + + let result = unsafe { + libc::sendto( + self.fd, + payload.as_ptr() as *const libc::c_void, + payload.len(), + 0, + &sa6 as *const _ as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + + if result < 0 { + return Err(TracerouteError::from(std::io::Error::last_os_error())); + } + } else { + // IPv4: Use IP_HDRINCL, but need to convert ip_len and ip_off to host byte order + self.write_buf[..buf.len()].copy_from_slice(buf); + + if buf.len() >= 20 { + // Convert ip_len (bytes 2-3) from network to host byte order + let ip_len = u16::from_be_bytes([self.write_buf[2], self.write_buf[3]]); + self.write_buf[2..4].copy_from_slice(&ip_len.to_ne_bytes()); + + // Convert ip_off (bytes 6-7) from network to host byte order + let ip_off = u16::from_be_bytes([self.write_buf[6], self.write_buf[7]]); + self.write_buf[6..8].copy_from_slice(&ip_off.to_ne_bytes()); + } + + let sa4 = libc::sockaddr_in { + sin_len: std::mem::size_of::() as u8, + sin_family: libc::AF_INET as u8, + sin_port: 0, + sin_addr: libc::in_addr { + s_addr: match addr.ip() { + IpAddr::V4(ip) => u32::from_ne_bytes(ip.octets()), + _ => unreachable!(), + }, + }, + sin_zero: [0; 8], + }; + + let result = unsafe { + libc::sendto( + self.fd, + self.write_buf.as_ptr() as *const libc::c_void, + buf.len(), + 0, + &sa4 as *const _ as *const libc::sockaddr, + std::mem::size_of::() as libc::socklen_t, + ) + }; + + if result < 0 { + return Err(TracerouteError::from(std::io::Error::last_os_error())); + } + } + + Ok(()) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + if self.fd > 0 { + unsafe { libc::close(self.fd) }; + self.fd = -1; + } + Ok(()) + } +} + +/// Creates a new source and sink for macOS. +pub async fn new_source_sink(target_addr: IpAddr) -> Result { + let sink = RawSink::new(target_addr)?; + let source = BpfDevice::new(target_addr)?; + + Ok(SourceSinkHandle { + source: Box::new(source), + sink: Box::new(sink), + must_close_port: false, + }) +} diff --git a/rust/crates/traceroute-packets/src/platform/linux.rs b/rust/crates/traceroute-packets/src/platform/linux.rs new file mode 100644 index 00000000..58416588 --- /dev/null +++ b/rust/crates/traceroute-packets/src/platform/linux.rs @@ -0,0 +1,304 @@ +//! Linux-specific packet I/O using AF_PACKET. + +use crate::{PacketFilterSpec, Sink, Source, SourceSinkHandle}; +use async_trait::async_trait; +use std::io::Read; +use std::net::{IpAddr, SocketAddr}; +use std::os::fd::{FromRawFd, RawFd}; +use std::time::Instant; +use traceroute_core::TracerouteError; + +// Constants from linux headers +const AF_PACKET: i32 = 17; +const SOCK_RAW: i32 = 3; +const ETH_P_ALL: u16 = 0x0003; +const SOL_SOCKET: i32 = 1; + +const AF_INET: i32 = 2; +const AF_INET6: i32 = 10; +const IPPROTO_RAW: i32 = 255; +const IP_HDRINCL: i32 = 3; +const IPV6_HDRINCL: i32 = 36; + +// Ethernet header size +const ETH_HLEN: usize = 14; + +/// Convert host to network byte order (big-endian) +fn htons(val: u16) -> u16 { + val.to_be() +} + +/// AF_PACKET-based packet source for Linux. +pub struct AfPacketSource { + fd: RawFd, + file: std::fs::File, + read_deadline: Option, +} + +impl AfPacketSource { + /// Creates a new AF_PACKET source. + pub fn new() -> Result { + // Note: We don't use SOCK_NONBLOCK here because we want SO_RCVTIMEO + // to work properly. With non-blocking sockets, read() returns + // immediately with EAGAIN/EWOULDBLOCK, ignoring SO_RCVTIMEO. + let fd = unsafe { libc::socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL) as i32) }; + + if fd < 0 { + return Err(TracerouteError::SocketCreation( + std::io::Error::last_os_error(), + )); + } + + let file = unsafe { std::fs::File::from_raw_fd(fd) }; + + Ok(Self { + fd, + file, + read_deadline: None, + }) + } + + /// Strips the Ethernet header from a packet, returning the IP payload. + fn strip_ethernet_header(buf: &[u8]) -> Result<&[u8], TracerouteError> { + if buf.len() < ETH_HLEN { + return Err(TracerouteError::PacketTooShort { + expected: ETH_HLEN, + actual: buf.len(), + }); + } + + // Check EtherType (bytes 12-13) + let ethertype = u16::from_be_bytes([buf[12], buf[13]]); + + // 0x0800 = IPv4, 0x86DD = IPv6 + if ethertype != 0x0800 && ethertype != 0x86DD { + return Err(TracerouteError::PacketMismatch); + } + + Ok(&buf[ETH_HLEN..]) + } +} + +#[async_trait] +impl Source for AfPacketSource { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + self.read_deadline = Some(deadline); + + // Set socket timeout + let duration = deadline.saturating_duration_since(Instant::now()); + let tv = libc::timeval { + tv_sec: duration.as_secs() as libc::time_t, + tv_usec: duration.subsec_micros() as libc::suseconds_t, + }; + + let result = unsafe { + libc::setsockopt( + self.fd, + SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + + if result < 0 { + return Err(TracerouteError::Internal(format!( + "Failed to set socket timeout: {}", + std::io::Error::last_os_error() + ))); + } + + Ok(()) + } + + async fn read(&mut self, buf: &mut [u8]) -> Result { + // Read raw packet including ethernet header + let mut raw_buf = vec![0u8; buf.len() + ETH_HLEN]; + + loop { + // The socket is blocking and has SO_RCVTIMEO set, so this will + // either return data, timeout (EAGAIN), or error. + let n = match (&self.file).read(&mut raw_buf) { + Ok(n) => n, + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + // Timeout occurred + return Err(TracerouteError::ReadTimeout); + } + Err(e) => return Err(TracerouteError::from(e)), + }; + + // Strip ethernet header + match Self::strip_ethernet_header(&raw_buf[..n]) { + Ok(payload) => { + let len = payload.len().min(buf.len()); + buf[..len].copy_from_slice(&payload[..len]); + return Ok(len); + } + Err(TracerouteError::PacketMismatch) => { + // Not an IP packet, update deadline and continue reading + // Re-apply the timeout for remaining time + if let Some(deadline) = self.read_deadline { + if Instant::now() >= deadline { + return Err(TracerouteError::ReadTimeout); + } + // Update socket timeout for remaining time + let _ = self.set_read_deadline(deadline); + } + continue; + } + Err(e) => return Err(e), + } + } + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + // File will be closed when dropped + Ok(()) + } + + fn set_packet_filter(&mut self, _spec: PacketFilterSpec) -> Result<(), TracerouteError> { + // TODO: Implement BPF filter + // For now, we'll filter in userspace + Ok(()) + } +} + +/// Raw socket-based packet sink for Linux. +pub struct RawSink { + fd: RawFd, + #[allow(dead_code)] + is_ipv6: bool, +} + +impl RawSink { + /// Creates a new raw socket sink. + pub fn new(addr: IpAddr) -> Result { + let (domain, protocol, hdrincl) = match addr { + IpAddr::V4(_) => (AF_INET, libc::IPPROTO_IP, IP_HDRINCL), + IpAddr::V6(_) => (AF_INET6, libc::IPPROTO_IPV6, IPV6_HDRINCL), + }; + + // Use blocking socket - sendto() for datagrams typically completes immediately + let fd = unsafe { libc::socket(domain, SOCK_RAW, IPPROTO_RAW) }; + + if fd < 0 { + return Err(TracerouteError::SocketCreation( + std::io::Error::last_os_error(), + )); + } + + // Set IP_HDRINCL to include IP header in packets + let one: i32 = 1; + let result = unsafe { + libc::setsockopt( + fd, + protocol, + hdrincl, + &one as *const _ as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + + if result < 0 { + unsafe { libc::close(fd) }; + return Err(TracerouteError::Internal(format!( + "Failed to set IP_HDRINCL: {}", + std::io::Error::last_os_error() + ))); + } + + Ok(Self { + fd, + is_ipv6: addr.is_ipv6(), + }) + } +} + +#[async_trait] +impl Sink for RawSink { + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError> { + let (sockaddr, sockaddr_len): (libc::sockaddr_storage, libc::socklen_t) = match addr { + SocketAddr::V4(v4) => { + let mut sa: libc::sockaddr_in = unsafe { std::mem::zeroed() }; + sa.sin_family = AF_INET as libc::sa_family_t; + sa.sin_port = v4.port().to_be(); + sa.sin_addr.s_addr = u32::from_ne_bytes(v4.ip().octets()); + + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + unsafe { + std::ptr::copy_nonoverlapping( + &sa as *const _ as *const u8, + &mut storage as *mut _ as *mut u8, + std::mem::size_of::(), + ); + } + ( + storage, + std::mem::size_of::() as libc::socklen_t, + ) + } + SocketAddr::V6(v6) => { + let mut sa: libc::sockaddr_in6 = unsafe { std::mem::zeroed() }; + sa.sin6_family = AF_INET6 as libc::sa_family_t; + sa.sin6_port = v6.port().to_be(); + sa.sin6_addr.s6_addr = v6.ip().octets(); + + let mut storage: libc::sockaddr_storage = unsafe { std::mem::zeroed() }; + unsafe { + std::ptr::copy_nonoverlapping( + &sa as *const _ as *const u8, + &mut storage as *mut _ as *mut u8, + std::mem::size_of::(), + ); + } + ( + storage, + std::mem::size_of::() as libc::socklen_t, + ) + } + }; + + let result = unsafe { + libc::sendto( + self.fd, + buf.as_ptr() as *const libc::c_void, + buf.len(), + 0, + &sockaddr as *const _ as *const libc::sockaddr, + sockaddr_len, + ) + }; + + if result < 0 { + return Err(TracerouteError::WriteFailed(std::io::Error::last_os_error())); + } + + Ok(()) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + unsafe { libc::close(self.fd) }; + Ok(()) + } +} + +impl Drop for RawSink { + fn drop(&mut self) { + unsafe { libc::close(self.fd) }; + } +} + +/// Creates a new source and sink for Linux. +pub async fn new_source_sink(target_addr: IpAddr) -> Result { + let source = AfPacketSource::new()?; + let sink = RawSink::new(target_addr)?; + + Ok(SourceSinkHandle { + source: Box::new(source), + sink: Box::new(sink), + must_close_port: false, + }) +} diff --git a/rust/crates/traceroute-packets/src/platform/mod.rs b/rust/crates/traceroute-packets/src/platform/mod.rs new file mode 100644 index 00000000..3b5efb32 --- /dev/null +++ b/rust/crates/traceroute-packets/src/platform/mod.rs @@ -0,0 +1,43 @@ +//! Platform-specific packet I/O implementations. + +#[cfg(target_os = "linux")] +pub mod linux; + +#[cfg(target_os = "macos")] +pub mod darwin; + +#[cfg(target_os = "windows")] +pub mod windows; + +use crate::SourceSinkHandle; +use std::net::IpAddr; +use traceroute_core::TracerouteError; + +/// Creates a Source and Sink appropriate for the current platform. +pub async fn new_source_sink( + _target_addr: IpAddr, + _use_driver: bool, +) -> Result { + #[cfg(target_os = "linux")] + return linux::new_source_sink(_target_addr).await; + + #[cfg(target_os = "macos")] + return darwin::new_source_sink(_target_addr).await; + + #[cfg(target_os = "windows")] + return windows::new_source_sink(_target_addr, _use_driver).await; + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + return Err(TracerouteError::Internal( + "Unsupported platform".to_string(), + )); +} + +/// Starts the platform driver if applicable (Windows only, no-op elsewhere). +pub fn start_driver() -> Result<(), TracerouteError> { + #[cfg(target_os = "windows")] + return windows::start_driver(); + + #[cfg(not(target_os = "windows"))] + Ok(()) +} diff --git a/rust/crates/traceroute-packets/src/platform/windows.rs b/rust/crates/traceroute-packets/src/platform/windows.rs new file mode 100644 index 00000000..0b0f4054 --- /dev/null +++ b/rust/crates/traceroute-packets/src/platform/windows.rs @@ -0,0 +1,617 @@ +//! Windows-specific packet I/O using raw sockets. + +use crate::{PacketFilterSpec, Sink, Source, SourceSinkHandle}; +use async_trait::async_trait; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Once; +use std::time::{Duration, Instant}; +use traceroute_core::TracerouteError; +use tracing::debug; + +#[cfg(target_os = "windows")] +use std::os::windows::io::RawSocket; + +/// Raw connection-based packet source and sink for Windows. +/// +/// This combines both Source and Sink functionality in a single struct, +/// as Windows raw sockets support both reading and writing. +pub struct RawConn { + #[cfg(target_os = "windows")] + socket: RawSocket, + #[cfg(not(target_os = "windows"))] + socket: i64, + deadline: Option, + closed: bool, +} + +impl RawConn { + /// Creates a new raw connection. + pub fn new(addr: IpAddr) -> Result { + // Windows only supports IPv4 raw sockets with IP_HDRINCL + match addr { + IpAddr::V4(_) => {} + IpAddr::V6(_) => { + return Err(TracerouteError::Internal( + "IPv6 raw sockets not supported on Windows".to_string(), + )); + } + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::{ + bind, setsockopt, socket, AF_INET, INVALID_SOCKET, IPPROTO_IP as WS_IPPROTO_IP, + IP_HDRINCL as WS_IP_HDRINCL, SOCKADDR_IN, SOCKET_ERROR, SOCK_RAW, + }; + + let s = unsafe { socket(AF_INET as i32, SOCK_RAW, WS_IPPROTO_IP) }; + if s == INVALID_SOCKET { + return Err(TracerouteError::SocketCreation( + std::io::Error::last_os_error(), + )); + } + + // Set IP_HDRINCL to include IP header in packets + let hdrincl: i32 = 1; + let result = unsafe { + setsockopt( + s, + WS_IPPROTO_IP, + WS_IP_HDRINCL, + &hdrincl as *const i32 as *const u8, + std::mem::size_of::() as i32, + ) + }; + if result == SOCKET_ERROR { + unsafe { windows_sys::Win32::Networking::WinSock::closesocket(s) }; + return Err(TracerouteError::Internal(format!( + "Failed to set IP_HDRINCL: {}", + std::io::Error::last_os_error() + ))); + } + + // Bind to INADDR_ANY (0.0.0.0) - required for recvfrom to work on Windows + // We bind to 0.0.0.0 to receive packets from any local interface + let sa = SOCKADDR_IN { + sin_family: AF_INET, + sin_port: 0, + sin_addr: windows_sys::Win32::Networking::WinSock::IN_ADDR { + S_un: windows_sys::Win32::Networking::WinSock::IN_ADDR_0 { + S_addr: 0, // INADDR_ANY + }, + }, + sin_zero: [0; 8], + }; + + let bind_result = unsafe { + bind( + s, + &sa as *const _ as *const _, + std::mem::size_of::() as i32, + ) + }; + if bind_result == SOCKET_ERROR { + let err = std::io::Error::last_os_error(); + unsafe { windows_sys::Win32::Networking::WinSock::closesocket(s) }; + return Err(TracerouteError::Internal(format!( + "Failed to bind raw socket: {}", + err + ))); + } + + debug!("Created and bound Windows raw socket to INADDR_ANY"); + + Ok(Self { + socket: s as RawSocket, + deadline: None, + closed: false, + }) + } + + #[cfg(not(target_os = "windows"))] + { + // Stub for non-Windows platforms (won't be used) + Err(TracerouteError::Internal( + "Windows raw sockets only available on Windows".to_string(), + )) + } + } + + fn get_timeout(&self) -> Duration { + if let Some(deadline) = self.deadline { + let now = Instant::now(); + if now >= deadline { + Duration::from_millis(1) // Minimum timeout + } else { + deadline.duration_since(now) + } + } else { + Duration::from_secs(1) + } + } + + /// Synchronous version of set_read_deadline for use with shared wrapper + pub fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + self.deadline = Some(deadline); + Ok(()) + } + + /// Synchronous version of read for use with shared wrapper + pub fn read_sync(&mut self, buf: &mut [u8]) -> Result { + if self.closed { + return Err(TracerouteError::Internal("Socket closed".to_string())); + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::{ + recvfrom, setsockopt, WSAGetLastError, SOCKET_ERROR, SOL_SOCKET, + SO_RCVTIMEO as WS_SO_RCVTIMEO, WSAEMSGSIZE as WS_WSAEMSGSIZE, + WSAETIMEDOUT as WS_WSAETIMEDOUT, + }; + + let timeout = self.get_timeout(); + let timeout_ms = timeout.as_millis() as i32; + + // Set receive timeout + let result = unsafe { + setsockopt( + self.socket as usize, + SOL_SOCKET, + WS_SO_RCVTIMEO, + &timeout_ms as *const i32 as *const u8, + std::mem::size_of::() as i32, + ) + }; + if result == SOCKET_ERROR { + return Err(TracerouteError::Internal(format!( + "Failed to set SO_RCVTIMEO: {}", + std::io::Error::last_os_error() + ))); + } + + let mut from_addr: windows_sys::Win32::Networking::WinSock::SOCKADDR_IN = + unsafe { std::mem::zeroed() }; + let mut from_len = + std::mem::size_of::() as i32; + + let n = unsafe { + recvfrom( + self.socket as usize, + buf.as_mut_ptr(), + buf.len() as i32, + 0, + &mut from_addr as *mut _ as *mut _, + &mut from_len, + ) + }; + + if n == SOCKET_ERROR { + let err = unsafe { WSAGetLastError() }; + if err == WS_WSAETIMEDOUT || err == WS_WSAEMSGSIZE { + return Err(TracerouteError::ReadTimeout); + } + return Err(TracerouteError::from(std::io::Error::from_raw_os_error( + err, + ))); + } + + // Windows returns -1 on errors, unlike Unix + if n < 0 { + return Ok(0); + } + + Ok(n as usize) + } + + #[cfg(not(target_os = "windows"))] + { + let _ = buf; + Err(TracerouteError::Internal("Not on Windows".to_string())) + } + } + + /// Synchronous version of close for use with shared wrapper + pub fn close_sync(&mut self) -> Result<(), TracerouteError> { + if !self.closed { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::closesocket; + unsafe { closesocket(self.socket as usize) }; + } + self.closed = true; + } + Ok(()) + } + + /// Synchronous version of set_packet_filter for use with shared wrapper + pub fn set_packet_filter_sync( + &mut self, + _spec: PacketFilterSpec, + ) -> Result<(), TracerouteError> { + // Packet filtering not supported on Windows raw sockets - no-op + Ok(()) + } + + /// Synchronous version of write_to for use with shared wrapper + pub fn write_to_sync(&self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError> { + if self.closed { + return Err(TracerouteError::Internal("Socket closed".to_string())); + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::{ + sendto, AF_INET, SOCKADDR_IN, SOCKET_ERROR, + }; + + let ip = match addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => { + return Err(TracerouteError::Internal( + "IPv6 not supported on Windows".to_string(), + )); + } + }; + + let sa = SOCKADDR_IN { + sin_family: AF_INET, + sin_port: addr.port().to_be(), + sin_addr: windows_sys::Win32::Networking::WinSock::IN_ADDR { + S_un: windows_sys::Win32::Networking::WinSock::IN_ADDR_0 { + S_addr: u32::from_ne_bytes(ip.octets()), + }, + }, + sin_zero: [0; 8], + }; + + let result = unsafe { + sendto( + self.socket as usize, + buf.as_ptr(), + buf.len() as i32, + 0, + &sa as *const _ as *const _, + std::mem::size_of::() as i32, + ) + }; + + if result == SOCKET_ERROR { + return Err(TracerouteError::from(std::io::Error::last_os_error())); + } + + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (buf, addr); + Err(TracerouteError::Internal("Not on Windows".to_string())) + } + } +} + +#[async_trait] +impl Source for RawConn { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + self.deadline = Some(deadline); + Ok(()) + } + + async fn read(&mut self, buf: &mut [u8]) -> Result { + if self.closed { + return Err(TracerouteError::Internal("Socket closed".to_string())); + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::{ + recvfrom, setsockopt, WSAGetLastError, SOCKET_ERROR, SOL_SOCKET, + SO_RCVTIMEO as WS_SO_RCVTIMEO, WSAEMSGSIZE as WS_WSAEMSGSIZE, + WSAETIMEDOUT as WS_WSAETIMEDOUT, + }; + + let timeout = self.get_timeout(); + let timeout_ms = timeout.as_millis() as i32; + + // Set receive timeout + let result = unsafe { + setsockopt( + self.socket as usize, + SOL_SOCKET, + WS_SO_RCVTIMEO, + &timeout_ms as *const i32 as *const u8, + std::mem::size_of::() as i32, + ) + }; + if result == SOCKET_ERROR { + return Err(TracerouteError::Internal(format!( + "Failed to set SO_RCVTIMEO: {}", + std::io::Error::last_os_error() + ))); + } + + let mut from_addr: windows_sys::Win32::Networking::WinSock::SOCKADDR_IN = + unsafe { std::mem::zeroed() }; + let mut from_len = + std::mem::size_of::() as i32; + + let n = unsafe { + recvfrom( + self.socket as usize, + buf.as_mut_ptr(), + buf.len() as i32, + 0, + &mut from_addr as *mut _ as *mut _, + &mut from_len, + ) + }; + + if n == SOCKET_ERROR { + let err = unsafe { WSAGetLastError() }; + if err == WS_WSAETIMEDOUT || err == WS_WSAEMSGSIZE { + return Err(TracerouteError::ReadTimeout); + } + return Err(TracerouteError::from(std::io::Error::from_raw_os_error( + err, + ))); + } + + // Windows returns -1 on errors, unlike Unix + if n < 0 { + return Ok(0); + } + + Ok(n as usize) + } + + #[cfg(not(target_os = "windows"))] + { + Err(TracerouteError::Internal("Not on Windows".to_string())) + } + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + if !self.closed { + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::closesocket; + unsafe { closesocket(self.socket as usize) }; + } + self.closed = true; + } + Ok(()) + } + + fn set_packet_filter(&mut self, _spec: PacketFilterSpec) -> Result<(), TracerouteError> { + // Packet filtering not supported on Windows raw sockets - no-op + Ok(()) + } +} + +#[async_trait] +impl Sink for RawConn { + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError> { + if self.closed { + return Err(TracerouteError::Internal("Socket closed".to_string())); + } + + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::Networking::WinSock::{ + sendto, AF_INET, SOCKADDR_IN, SOCKET_ERROR, + }; + + let ip = match addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => { + return Err(TracerouteError::Internal( + "IPv6 not supported on Windows".to_string(), + )); + } + }; + + let sa = SOCKADDR_IN { + sin_family: AF_INET, + sin_port: addr.port().to_be(), + sin_addr: windows_sys::Win32::Networking::WinSock::IN_ADDR { + S_un: windows_sys::Win32::Networking::WinSock::IN_ADDR_0 { + S_addr: u32::from_ne_bytes(ip.octets()), + }, + }, + sin_zero: [0; 8], + }; + + let result = unsafe { + sendto( + self.socket as usize, + buf.as_ptr(), + buf.len() as i32, + 0, + &sa as *const _ as *const _, + std::mem::size_of::() as i32, + ) + }; + + if result == SOCKET_ERROR { + return Err(TracerouteError::from(std::io::Error::last_os_error())); + } + + Ok(()) + } + + #[cfg(not(target_os = "windows"))] + { + Err(TracerouteError::Internal("Not on Windows".to_string())) + } + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + ::close(self).await + } +} + +/// Placeholder for driver-based packet source (uses Datadog agent driver). +#[cfg(feature = "driver")] +pub struct SourceDriver { + read_deadline: Option, + // Driver handle would go here +} + +#[cfg(feature = "driver")] +impl SourceDriver { + /// Creates a new driver-based source. + pub fn new(_addr: IpAddr) -> Result { + // TODO: Implement driver FFI + Err(TracerouteError::DriverNotAvailable) + } +} + +#[cfg(feature = "driver")] +#[async_trait] +impl Source for SourceDriver { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + self.read_deadline = Some(deadline); + Ok(()) + } + + async fn read(&mut self, _buf: &mut [u8]) -> Result { + Err(TracerouteError::DriverNotAvailable) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + Ok(()) + } + + fn set_packet_filter(&mut self, _spec: PacketFilterSpec) -> Result<(), TracerouteError> { + Ok(()) + } +} + +/// Placeholder for driver-based packet sink (uses Datadog agent driver). +#[cfg(feature = "driver")] +pub struct SinkDriver { + // Driver handle would go here +} + +#[cfg(feature = "driver")] +impl SinkDriver { + /// Creates a new driver-based sink. + pub fn new(_addr: IpAddr) -> Result { + // TODO: Implement driver FFI + Err(TracerouteError::DriverNotAvailable) + } +} + +#[cfg(feature = "driver")] +#[async_trait] +impl Sink for SinkDriver { + async fn write_to(&mut self, _buf: &[u8], _addr: SocketAddr) -> Result<(), TracerouteError> { + Err(TracerouteError::DriverNotAvailable) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + Ok(()) + } +} + +/// Driver initialization once-guard. +static DRIVER_INIT: Once = Once::new(); + +/// Starts the Windows driver. +pub fn start_driver() -> Result<(), TracerouteError> { + #[cfg(feature = "driver")] + { + let result = Ok(()); + DRIVER_INIT.call_once(|| { + // TODO: Initialize driver + // result = driver::init(); + }); + result + } + + #[cfg(not(feature = "driver"))] + Err(TracerouteError::DriverNotAvailable) +} + +/// Creates a new source and sink for Windows. +pub async fn new_source_sink( + target_addr: IpAddr, + use_driver: bool, +) -> Result { + if use_driver { + #[cfg(feature = "driver")] + { + start_driver()?; + let source = SourceDriver::new(target_addr)?; + let sink = SinkDriver::new(target_addr)?; + return Ok(SourceSinkHandle { + source: Box::new(source), + sink: Box::new(sink), + must_close_port: false, + }); + } + + #[cfg(not(feature = "driver"))] + return Err(TracerouteError::DriverNotAvailable); + } + + // Use raw socket mode with a shared socket wrapped in Arc + // Windows raw sockets work best when the same socket is used for both + // reading and writing, matching the Go implementation behavior + let shared_conn = std::sync::Arc::new(std::sync::Mutex::new(RawConn::new(target_addr)?)); + + Ok(SourceSinkHandle { + source: Box::new(SharedRawConn { + inner: shared_conn.clone(), + }), + sink: Box::new(SharedRawConnSink { inner: shared_conn }), + must_close_port: true, // Windows raw sockets require closing the port before receiving + }) +} + +/// Wrapper for shared RawConn as Source +pub struct SharedRawConn { + inner: std::sync::Arc>, +} + +#[async_trait] +impl Source for SharedRawConn { + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError> { + let mut conn = self.inner.lock().unwrap(); + conn.set_read_deadline(deadline) + } + + async fn read(&mut self, buf: &mut [u8]) -> Result { + let mut conn = self.inner.lock().unwrap(); + conn.read_sync(buf) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + let mut conn = self.inner.lock().unwrap(); + conn.close_sync() + } + + fn set_packet_filter(&mut self, spec: PacketFilterSpec) -> Result<(), TracerouteError> { + let mut conn = self.inner.lock().unwrap(); + conn.set_packet_filter_sync(spec) + } +} + +/// Wrapper for shared RawConn as Sink +pub struct SharedRawConnSink { + inner: std::sync::Arc>, +} + +#[async_trait] +impl Sink for SharedRawConnSink { + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError> { + let conn = self.inner.lock().unwrap(); + conn.write_to_sync(buf, addr) + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + // Don't close here - the source wrapper will close + Ok(()) + } +} diff --git a/rust/crates/traceroute-packets/src/sink.rs b/rust/crates/traceroute-packets/src/sink.rs new file mode 100644 index 00000000..f69f782a --- /dev/null +++ b/rust/crates/traceroute-packets/src/sink.rs @@ -0,0 +1,15 @@ +//! Packet transmission sink trait. + +use async_trait::async_trait; +use std::net::SocketAddr; +use traceroute_core::TracerouteError; + +/// Trait for packet transmission. +#[async_trait] +pub trait Sink: Send + Sync { + /// Writes an IP packet to the given address. + async fn write_to(&mut self, buf: &[u8], addr: SocketAddr) -> Result<(), TracerouteError>; + + /// Closes the sink. + async fn close(&mut self) -> Result<(), TracerouteError>; +} diff --git a/rust/crates/traceroute-packets/src/source.rs b/rust/crates/traceroute-packets/src/source.rs new file mode 100644 index 00000000..dfbba176 --- /dev/null +++ b/rust/crates/traceroute-packets/src/source.rs @@ -0,0 +1,46 @@ +//! Packet capture source trait. + +use async_trait::async_trait; +use std::time::Instant; +use traceroute_core::TracerouteError; + +/// Filter type for packet capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterType { + /// Filter for ICMP packets. + Icmp, + /// Filter for UDP packets. + Udp, + /// Filter for TCP packets. + Tcp, + /// Filter for TCP SYN/ACK packets. + SynAck, +} + +/// Specification for packet filtering. +#[derive(Debug, Clone)] +pub struct PacketFilterSpec { + /// Type of filter to apply. + pub filter_type: FilterType, + /// Source port to filter on (if applicable). + pub src_port: Option, + /// Destination port to filter on (if applicable). + pub dst_port: Option, +} + +/// Trait for packet capture sources. +#[async_trait] +pub trait Source: Send + Sync { + /// Sets the read deadline for subsequent read operations. + fn set_read_deadline(&mut self, deadline: Instant) -> Result<(), TracerouteError>; + + /// Reads a packet (starting at IP layer) into the buffer. + /// Returns the number of bytes read. + async fn read(&mut self, buf: &mut [u8]) -> Result; + + /// Closes the source. + async fn close(&mut self) -> Result<(), TracerouteError>; + + /// Sets a packet filter (BPF on Linux/macOS, driver filter on Windows). + fn set_packet_filter(&mut self, spec: PacketFilterSpec) -> Result<(), TracerouteError>; +} diff --git a/rust/crates/traceroute-sack/Cargo.toml b/rust/crates/traceroute-sack/Cargo.toml new file mode 100644 index 00000000..38f00679 --- /dev/null +++ b/rust/crates/traceroute-sack/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "traceroute-sack" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "TCP SACK traceroute implementation" + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +pnet = { workspace = true } +pnet_packet = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-sack/src/driver.rs b/rust/crates/traceroute-sack/src/driver.rs new file mode 100644 index 00000000..c6bac072 --- /dev/null +++ b/rust/crates/traceroute-sack/src/driver.rs @@ -0,0 +1,470 @@ +//! TCP SACK traceroute driver implementation. + +use crate::packet::{create_sack_packet, SackTcpState}; +use async_trait::async_trait; +use std::net::{IpAddr, SocketAddr}; +use std::time::{Duration, Instant}; +use traceroute_core::{ProbeResponse, TracerouteDriver, TracerouteDriverInfo, TracerouteError}; +use traceroute_packets::{parse_tcp_first_bytes, FrameParser, Sink, Source}; +use tracing::{debug, trace}; + +/// Error indicating SACK is not supported by the target. +#[derive(Debug, thiserror::Error)] +#[error("SACK not supported: {message}")] +pub struct SackNotSupportedError { + /// Detailed error message. + pub message: String, +} + +/// TCP SACK traceroute driver. +pub struct SackDriver { + /// Source IP address. + src_ip: IpAddr, + /// Source port. + src_port: u16, + /// Target IP address. + target_ip: IpAddr, + /// Target port. + target_port: u16, + /// Packet source for receiving. + source: Box, + /// Packet sink for sending. + sink: Box, + /// Read buffer. + buffer: Vec, + /// Frame parser. + parser: FrameParser, + /// Send times for each TTL. + send_times: Vec>, + /// TCP state from handshake. + state: Option, + /// Whether to loosen ICMP source checking. + loosen_icmp_src: bool, + /// Minimum TTL. + min_ttl: u8, + /// Maximum TTL. + max_ttl: u8, +} + +impl SackDriver { + /// Creates a new SACK driver. + /// + /// Note: The driver must complete the handshake phase via `read_handshake()` + /// before it can send probes. + pub fn new( + src_ip: IpAddr, + target_ip: IpAddr, + target_port: u16, + source: Box, + sink: Box, + min_ttl: u8, + max_ttl: u8, + ) -> Self { + let send_times = vec![None; (max_ttl as usize) + 1]; + + Self { + src_ip, + src_port: 0, // Set by read_handshake + target_ip, + target_port, + source, + sink, + buffer: vec![0u8; 1500], + parser: FrameParser::new(), + send_times, + state: None, + loosen_icmp_src: false, + min_ttl, + max_ttl, + } + } + + /// Sets whether to loosen ICMP source checking. + pub fn set_loosen_icmp_src(&mut self, loosen: bool) { + self.loosen_icmp_src = loosen; + } + + /// Returns whether the handshake is complete. + pub fn is_handshake_finished(&self) -> bool { + self.state.is_some() + } + + /// Performs a fake handshake for debugging purposes. + pub fn fake_handshake(&mut self, local_port: u16, local_init_seq: u32, local_init_ack: u32) { + self.src_port = local_port; + self.state = Some(SackTcpState { + local_init_seq, + local_init_ack, + has_ts: false, + ts_value: 0, + ts_ecr: 0, + }); + } + + /// Reads the handshake response (SYN/ACK) from the target. + /// + /// This must be called after initiating a TCP connection to the target. + /// It will wait for the SYN/ACK and verify that SACK is supported. + pub async fn read_handshake(&mut self, local_port: u16) -> Result<(), TracerouteError> { + self.src_port = local_port; + + let deadline = Instant::now() + Duration::from_millis(500); + self.source.set_read_deadline(deadline)?; + + while !self.is_handshake_finished() { + let n = match self.source.read(&mut self.buffer).await { + Ok(n) => n, + Err(TracerouteError::ReadTimeout) => { + return Err(TracerouteError::Internal( + "SACK handshake timed out".to_string(), + )); + } + Err(e) if e.is_retryable() => continue, + Err(e) => return Err(e), + }; + + if let Err(e) = self.parser.parse(&self.buffer[..n]) { + debug!(error = %e, "Failed to parse packet during handshake"); + continue; + } + + self.handle_handshake()?; + } + + Ok(()) + } + + fn handle_handshake(&mut self) -> Result<(), TracerouteError> { + // Must be TCP + if !self.parser.is_tcp() { + return Ok(()); + } + + // Check IP pair (should be from target to us) + let ip_pair = self.parser.get_ip_pair(); + if ip_pair.src_addr != Some(self.target_ip) || ip_pair.dst_addr != Some(self.src_ip) { + return Ok(()); + } + + // Check ports + let tcp_info = match self.parser.tcp_info { + Some(info) => info, + None => return Ok(()), + }; + + if tcp_info.src_port != self.target_port || tcp_info.dst_port != self.src_port { + debug!( + expected_src = self.target_port, + actual_src = tcp_info.src_port, + expected_dst = self.src_port, + actual_dst = tcp_info.dst_port, + "Ports don't match in handshake" + ); + return Ok(()); + } + + // Must be SYN/ACK + if !self.parser.is_syn_ack { + return Ok(()); + } + + // Check for SACK-Permitted option + // Note: We need to parse TCP options from the raw packet + // For now, we'll trust that the connection supports SACK + // A full implementation would parse the TCP options + + // TODO: Parse TCP options and check for SACK-Permitted (kind=4) + // For now, we assume SACK is supported if we get a SYN/ACK + + // Extract ACK and SEQ from the response + // The tcp_info.seq contains the server's SEQ + // We need to get our initial seq from the ACK field + + // For SACK traceroute: + // - local_init_seq = their ACK (this is NOT ACK-1 because we need a gap) + // - local_init_ack = their SEQ + 1 + + // Note: We don't have direct access to the ACK field in TcpInfo + // We'd need to extend the parser or use a different approach + + // For now, use placeholder values that would work in testing + let state = SackTcpState { + local_init_seq: tcp_info.seq + 1, // Simplified + local_init_ack: tcp_info.seq + 1, + has_ts: false, // TODO: Parse timestamp option + ts_value: 0, + ts_ecr: 0, + }; + + trace!( + local_init_seq = state.local_init_seq, + local_init_ack = state.local_init_ack, + "SACK handshake completed" + ); + + self.state = Some(state); + Ok(()) + } + + fn get_rtt_from_rel_seq(&self, rel_seq: u32) -> Result { + if rel_seq < self.min_ttl as u32 || rel_seq > self.max_ttl as u32 { + return Err(TracerouteError::MalformedPacket(format!( + "Invalid relative sequence number {}", + rel_seq + ))); + } + + match self.send_times.get(rel_seq as usize).and_then(|t| *t) { + Some(send_time) => Ok(send_time.elapsed()), + None => Err(TracerouteError::MalformedPacket(format!( + "No probe sent for relative sequence number {}", + rel_seq + ))), + } + } + + fn get_local_addr(&self) -> SocketAddr { + SocketAddr::new(self.src_ip, self.src_port) + } + + fn get_target_addr(&self) -> SocketAddr { + SocketAddr::new(self.target_ip, self.target_port) + } + + async fn handle_probe_layers(&mut self) -> Result, TracerouteError> { + let ip_pair = self.parser.get_ip_pair(); + + // Check for TCP response with SACK options + if self.parser.is_tcp() { + return self.handle_tcp_response(&ip_pair).await; + } + + // Check for ICMP TTL Exceeded + if self.parser.is_icmp() && self.parser.is_ttl_exceeded() { + return self.handle_icmp_response(&ip_pair).await; + } + + Err(TracerouteError::PacketMismatch) + } + + async fn handle_tcp_response( + &self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + // Check IP pair + if ip_pair.src_addr != Some(self.target_ip) || ip_pair.dst_addr != Some(self.src_ip) { + return Err(TracerouteError::PacketMismatch); + } + + // Check ports + let tcp_info = self + .parser + .tcp_info + .ok_or_else(|| TracerouteError::MalformedPacket("Missing TCP info".to_string()))?; + + if tcp_info.src_port != self.target_port || tcp_info.dst_port != self.src_port { + return Err(TracerouteError::PacketMismatch); + } + + // We only care about ACK packets (not SYN, FIN, RST) + if self.parser.is_syn_ack || self.parser.is_rst { + return Err(TracerouteError::PacketMismatch); + } + + let _state = self + .state + .as_ref() + .ok_or_else(|| TracerouteError::Internal("Handshake not completed".to_string()))?; + + // TODO: Parse SACK options from the TCP packet + // For now, we'd need to extend the parser to provide raw TCP options + // The minimum SACK value indicates the earliest TTL that arrived + + // This is a placeholder - in production, we'd parse the SACK options + // from the raw packet buffer and use get_min_sack_from_options() + + // Return error indicating we need SACK options + Err(TracerouteError::MalformedPacket( + "SACK option parsing not yet implemented".to_string(), + )) + } + + async fn handle_icmp_response( + &self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + let icmp_info = self + .parser + .get_icmp_info() + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP info".to_string()))?; + + // Parse TCP info from ICMP payload + let tcp_info = parse_tcp_first_bytes(&icmp_info.payload).map_err(|e| { + TracerouteError::MalformedPacket(format!("Failed to parse TCP info: {}", e)) + })?; + + // Check destination matches our target + let icmp_dst = SocketAddr::new( + icmp_info + .icmp_pair + .dst_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + tcp_info.dst_port, + ); + + if icmp_dst != self.get_target_addr() { + trace!( + expected = %self.get_target_addr(), + actual = %icmp_dst, + "ICMP destination mismatch" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Check source if not loosened + if !self.loosen_icmp_src { + let icmp_src = SocketAddr::new( + icmp_info + .icmp_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + tcp_info.src_port, + ); + + if icmp_src != self.get_local_addr() { + trace!( + expected = %self.get_local_addr(), + actual = %icmp_src, + "ICMP source mismatch" + ); + return Err(TracerouteError::PacketMismatch); + } + } + + let state = self + .state + .as_ref() + .ok_or_else(|| TracerouteError::Internal("Handshake not completed".to_string()))?; + + // Calculate relative sequence number + let rel_seq = tcp_info.seq.wrapping_sub(state.local_init_seq); + let rtt = self.get_rtt_from_rel_seq(rel_seq)?; + + let src_addr = ip_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + + // If the ICMP came from the target, we've reached the destination + let is_dest = src_addr == self.target_ip; + + Ok(Some(ProbeResponse { + ttl: rel_seq as u8, + ip: src_addr, + rtt, + is_dest, + })) + } +} + +#[async_trait] +impl TracerouteDriver for SackDriver { + fn get_driver_info(&self) -> TracerouteDriverInfo { + TracerouteDriverInfo { + // SACK supports parallel mode because each probe has a unique + // sequence number that identifies which TTL it was for + supports_parallel: true, + } + } + + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError> { + if !self.is_handshake_finished() { + return Err(TracerouteError::Internal( + "Handshake not completed".to_string(), + )); + } + + if ttl < self.min_ttl || ttl > self.max_ttl { + return Err(TracerouteError::Internal(format!( + "Asked to send invalid TTL {}", + ttl + ))); + } + + // Check if we've already sent this probe + if self.send_times[ttl as usize].is_some() { + return Err(TracerouteError::Internal(format!( + "Already sent probe for TTL {}", + ttl + ))); + } + + self.send_times[ttl as usize] = Some(Instant::now()); + + let state = self.state.as_ref().unwrap(); + + let packet = create_sack_packet( + self.src_ip, + self.target_ip, + self.src_port, + self.target_port, + ttl, + state, + )?; + + trace!( + ttl = ttl, + seq = state.local_init_seq + ttl as u32, + "Sending SACK probe" + ); + + self.sink.write_to(&packet, self.get_target_addr()).await?; + + Ok(()) + } + + async fn receive_probe( + &mut self, + timeout: Duration, + ) -> Result, TracerouteError> { + if !self.is_handshake_finished() { + return Err(TracerouteError::Internal( + "Handshake not completed".to_string(), + )); + } + + let deadline = Instant::now() + timeout; + self.source.set_read_deadline(deadline)?; + + let n = self.source.read(&mut self.buffer).await?; + + if let Err(e) = self.parser.parse(&self.buffer[..n]) { + debug!(error = %e, "Failed to parse packet"); + return Err(e); + } + + self.handle_probe_layers().await + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + let sink_result = self.sink.close().await; + let source_result = self.source.close().await; + + sink_result?; + source_result?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_driver_info() { + let info = TracerouteDriverInfo { + supports_parallel: true, + }; + assert!(info.supports_parallel); + } +} diff --git a/rust/crates/traceroute-sack/src/lib.rs b/rust/crates/traceroute-sack/src/lib.rs new file mode 100644 index 00000000..b6d3952a --- /dev/null +++ b/rust/crates/traceroute-sack/src/lib.rs @@ -0,0 +1,7 @@ +//! TCP SACK traceroute implementation. + +mod driver; +mod packet; + +pub use driver::{SackDriver, SackNotSupportedError}; +pub use packet::{create_sack_packet, get_min_sack_from_options, SackTcpState}; diff --git a/rust/crates/traceroute-sack/src/packet.rs b/rust/crates/traceroute-sack/src/packet.rs new file mode 100644 index 00000000..1f8c66cc --- /dev/null +++ b/rust/crates/traceroute-sack/src/packet.rs @@ -0,0 +1,323 @@ +//! TCP SACK packet construction using pnet. + +use pnet_packet::ip::IpNextHeaderProtocols; +use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet}; +use pnet_packet::tcp::{MutableTcpPacket, TcpFlags, TcpOption}; +use std::net::{IpAddr, Ipv4Addr}; +use traceroute_core::TracerouteError; + +/// TCP state for SACK traceroute. +#[derive(Debug, Clone)] +pub struct SackTcpState { + /// Initial sequence number from our side. + pub local_init_seq: u32, + /// Initial acknowledgment number (server's seq + 1). + pub local_init_ack: u32, + /// Whether timestamps are enabled. + pub has_ts: bool, + /// Timestamp value to send. + pub ts_value: u32, + /// Timestamp echo reply. + pub ts_ecr: u32, +} + +/// Packet ID for SACK packets. +const SACK_PACKET_ID: u16 = 41821; + +/// TCP window size. +const TCP_WINDOW_SIZE: u16 = 1024; + +/// Creates a TCP SACK probe packet. +/// +/// The packet is an ACK+PSH packet with sequence number based on the TTL. +pub fn create_sack_packet( + src_ip: IpAddr, + dst_ip: IpAddr, + src_port: u16, + dst_port: u16, + ttl: u8, + state: &SackTcpState, +) -> Result, TracerouteError> { + match (src_ip, dst_ip) { + (IpAddr::V4(src), IpAddr::V4(dst)) => { + create_sack_packet_v4(src, dst, src_port, dst_port, ttl, state) + } + (IpAddr::V6(_src), IpAddr::V6(_dst)) => { + // TODO: Implement IPv6 + Err(TracerouteError::Internal( + "IPv6 not yet implemented".to_string(), + )) + } + _ => Err(TracerouteError::Internal( + "IP version mismatch between source and destination".to_string(), + )), + } +} + +fn create_sack_packet_v4( + src_ip: Ipv4Addr, + dst_ip: Ipv4Addr, + src_port: u16, + dst_port: u16, + ttl: u8, + state: &SackTcpState, +) -> Result, TracerouteError> { + // Calculate TCP options length + let options_len = if state.has_ts { + 12 // Timestamp (10 bytes) + 2 NOPs for alignment + } else { + 0 + }; + + // TCP header: 20 bytes base + options + let tcp_header_len = 20 + options_len; + let payload_len = 1; // Single byte payload (the TTL) + let ip_len = 20 + tcp_header_len + payload_len; + + let mut buffer = vec![0u8; ip_len]; + + // Create IPv4 packet + let mut ip_packet = MutableIpv4Packet::new(&mut buffer) + .ok_or_else(|| TracerouteError::Internal("Failed to create IP packet".to_string()))?; + + ip_packet.set_version(4); + ip_packet.set_header_length(5); + ip_packet.set_total_length(ip_len as u16); + ip_packet.set_identification(SACK_PACKET_ID); + ip_packet.set_flags(Ipv4Flags::DontFragment); + ip_packet.set_ttl(ttl); + ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Tcp); + ip_packet.set_source(src_ip); + ip_packet.set_destination(dst_ip); + + // Calculate IP checksum + let ip_checksum = pnet_packet::ipv4::checksum(&ip_packet.to_immutable()); + ip_packet.set_checksum(ip_checksum); + + // Create TCP packet in the payload section + let tcp_start = 20; + let buffer_len = buffer.len(); + { + let mut tcp_packet = MutableTcpPacket::new(&mut buffer[tcp_start..]) + .ok_or_else(|| TracerouteError::Internal("Failed to create TCP packet".to_string()))?; + + tcp_packet.set_source(src_port); + tcp_packet.set_destination(dst_port); + // Sequence number is based on TTL + tcp_packet.set_sequence(state.local_init_seq + ttl as u32); + tcp_packet.set_acknowledgement(state.local_init_ack); + // Data offset in 32-bit words + tcp_packet.set_data_offset((tcp_header_len / 4) as u8); + // ACK + PSH flags + tcp_packet.set_flags(TcpFlags::ACK | TcpFlags::PSH); + tcp_packet.set_window(TCP_WINDOW_SIZE); + tcp_packet.set_urgent_ptr(0); + + // Set TCP options if timestamps are enabled + if state.has_ts { + let mut options_data = vec![0u8; options_len]; + // NOP (kind 1) + options_data[0] = 1; + // NOP (kind 1) + options_data[1] = 1; + // Timestamp option + options_data[2] = 8; // kind + options_data[3] = 10; // length + // TS Value (4 bytes) + let ts_val = state.ts_value + ttl as u32; + options_data[4..8].copy_from_slice(&ts_val.to_be_bytes()); + // TS Echo Reply (4 bytes) + options_data[8..12].copy_from_slice(&state.ts_ecr.to_be_bytes()); + + tcp_packet.set_options(&[ + TcpOption::nop(), + TcpOption::nop(), + TcpOption::timestamp(ts_val, state.ts_ecr), + ]); + } + + // Set payload (single byte with TTL) + let payload_start = tcp_header_len; + if payload_start < buffer_len - tcp_start { + tcp_packet.set_payload(&[ttl]); + } + + // Calculate TCP checksum + let tcp_checksum = + pnet_packet::tcp::ipv4_checksum(&tcp_packet.to_immutable(), &src_ip, &dst_ip); + tcp_packet.set_checksum(tcp_checksum); + } + + Ok(buffer) +} + +/// Parses SACK option from TCP options and returns the minimum SACK left edge. +/// +/// The minimum SACK left edge relative to local_init_seq indicates the earliest +/// TTL that was received at the destination. +pub fn get_min_sack_from_options(local_init_seq: u32, options: &[u8]) -> Option { + let mut min_sack: Option = None; + let mut i = 0; + + while i < options.len() { + let kind = options[i]; + match kind { + 0 => break, // End of options + 1 => { + // NOP + i += 1; + } + 5 => { + // SACK + if i + 1 >= options.len() { + break; + } + let len = options[i + 1] as usize; + if i + len > options.len() || len < 2 { + break; + } + // SACK data starts at i + 2 + let sack_data = &options[i + 2..i + len]; + // Each SACK block is 8 bytes (left edge + right edge) + for block in sack_data.chunks(8) { + if block.len() >= 4 { + let left_edge = + u32::from_be_bytes([block[0], block[1], block[2], block[3]]); + let relative_left = left_edge.wrapping_sub(local_init_seq); + match min_sack { + Some(current) if relative_left < current => { + min_sack = Some(relative_left); + } + None => { + min_sack = Some(relative_left); + } + _ => {} + } + } + } + i += len; + } + _ => { + // Other options + if i + 1 >= options.len() { + break; + } + let len = options[i + 1] as usize; + if len < 2 || i + len > options.len() { + break; + } + i += len; + } + } + } + + min_sack +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_sack_packet_without_ts() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + let state = SackTcpState { + local_init_seq: 1000, + local_init_ack: 2000, + has_ts: false, + ts_value: 0, + ts_ecr: 0, + }; + + let result = + create_sack_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), 12345, 80, 5, &state); + + assert!(result.is_ok()); + let packet = result.unwrap(); + + // Check packet length (20 IP + 20 TCP + 1 payload = 41) + assert_eq!(packet.len(), 41); + + // Check IP version + assert_eq!(packet[0] >> 4, 4); + + // Check TTL + assert_eq!(packet[8], 5); + + // Check protocol (TCP = 6) + assert_eq!(packet[9], 6); + + // Check TCP flags (ACK=0x10, PSH=0x08) + assert_eq!(packet[33] & 0x3f, 0x18); + } + + #[test] + fn test_sack_sequence_number() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + let state = SackTcpState { + local_init_seq: 1000, + local_init_ack: 2000, + has_ts: false, + ts_value: 0, + ts_ecr: 0, + }; + + let ttl = 10u8; + let packet = create_sack_packet( + IpAddr::V4(src_ip), + IpAddr::V4(dst_ip), + 12345, + 80, + ttl, + &state, + ) + .unwrap(); + + // Sequence number is at TCP offset + 4 (bytes 24-27) + let tcp_start = 20; + let seq = u32::from_be_bytes([ + packet[tcp_start + 4], + packet[tcp_start + 5], + packet[tcp_start + 6], + packet[tcp_start + 7], + ]); + + assert_eq!(seq, state.local_init_seq + ttl as u32); + } + + #[test] + fn test_get_min_sack() { + let local_init_seq: u32 = 1000; + + // Construct SACK option: kind=5, len=10, one block (left=1005, right=1006) + let options = vec![ + 5, // kind + 10, // length + 0, 0, 0x03, 0xED, // left edge = 1005 + 0, 0, 0x03, 0xEE, // right edge = 1006 + ]; + + let min_sack = get_min_sack_from_options(local_init_seq, &options); + assert_eq!(min_sack, Some(5)); // 1005 - 1000 = 5 + } + + #[test] + fn test_get_min_sack_multiple_blocks() { + let local_init_seq: u32 = 1000; + + // Two SACK blocks: (1010, 1011) and (1005, 1006) + let options = vec![ + 5, // kind + 18, // length (2 + 8 + 8) + 0, 0, 0x03, 0xF2, // left edge = 1010 + 0, 0, 0x03, 0xF3, // right edge = 1011 + 0, 0, 0x03, 0xED, // left edge = 1005 + 0, 0, 0x03, 0xEE, // right edge = 1006 + ]; + + let min_sack = get_min_sack_from_options(local_init_seq, &options); + assert_eq!(min_sack, Some(5)); // min(10, 5) = 5 + } +} diff --git a/rust/crates/traceroute-server/Cargo.toml b/rust/crates/traceroute-server/Cargo.toml new file mode 100644 index 00000000..34ac90f2 --- /dev/null +++ b/rust/crates/traceroute-server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "traceroute-server" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "HTTP REST API server for datadog-traceroute" + +[[bin]] +name = "datadog-traceroute-server" +path = "src/main.rs" + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +traceroute-udp = { workspace = true } +traceroute-tcp = { workspace = true } +traceroute-icmp = { workspace = true } +traceroute-sack = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +clap = { workspace = true } +hickory-resolver = { workspace = true } +reqwest = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-server/src/handlers.rs b/rust/crates/traceroute-server/src/handlers.rs new file mode 100644 index 00000000..3dcbc819 --- /dev/null +++ b/rust/crates/traceroute-server/src/handlers.rs @@ -0,0 +1,130 @@ +//! HTTP request handlers. + +use crate::runner; +use axum::{extract::Query, http::StatusCode, routing::get, Json, Router}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use traceroute_core::{Protocol, Results, TracerouteConfig, TracerouteParams}; + +/// Query parameters for the traceroute endpoint. +#[derive(Debug, Deserialize)] +pub struct TracerouteQuery { + /// Target hostname or IP address. + pub target: String, + /// Protocol to use (udp, tcp, icmp). + #[serde(default = "default_protocol")] + pub protocol: String, + /// Destination port. + #[serde(default = "default_port")] + pub port: u16, + /// Maximum TTL. + #[serde(default = "default_max_ttl")] + pub max_ttl: Option, + /// Timeout per probe in milliseconds. + #[serde(default = "default_timeout")] + pub timeout: Option, + /// Number of traceroute queries. + #[serde(default = "default_queries")] + pub queries: Option, + /// Use IPv6. + #[serde(default)] + pub ipv6: bool, +} + +fn default_protocol() -> String { + "udp".to_string() +} + +fn default_port() -> u16 { + 33434 +} + +fn default_max_ttl() -> Option { + Some(30) +} + +fn default_timeout() -> Option { + Some(3000) +} + +fn default_queries() -> Option { + Some(3) +} + +/// Error response. +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +/// Creates the Axum router with all endpoints. +pub fn create_router() -> Router { + Router::new() + .route("/traceroute", get(handle_traceroute)) + .route("/health", get(handle_health)) +} + +/// Health check endpoint. +async fn handle_health() -> &'static str { + "ok" +} + +/// Handles the GET /traceroute endpoint. +async fn handle_traceroute( + Query(params): Query, +) -> Result, (StatusCode, Json)> { + // Parse protocol + let protocol: Protocol = params.protocol.parse().map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid protocol: {}", e), + }), + ) + })?; + + // Build config + let config = TracerouteConfig { + hostname: params.target, + port: params.port, + protocol, + params: TracerouteParams { + min_ttl: 1, + max_ttl: params.max_ttl.unwrap_or(30), + timeout: Duration::from_millis(params.timeout.unwrap_or(3000)), + poll_frequency: Duration::from_millis(100), + send_delay: Duration::from_millis(50), + }, + tcp_method: traceroute_core::TcpMethod::Syn, + want_v6: params.ipv6, + reverse_dns: false, + collect_source_public_ip: false, + traceroute_queries: params.queries.unwrap_or(3), + e2e_queries: 0, + use_windows_driver: false, + skip_private_hops: false, + }; + + // Run traceroute + match runner::run_traceroute(config).await { + Ok(results) => Ok(Json(results)), + Err(e) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Traceroute failed: {}", e), + }), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_values() { + assert_eq!(default_protocol(), "udp"); + assert_eq!(default_port(), 33434); + assert_eq!(default_max_ttl(), Some(30)); + } +} diff --git a/rust/crates/traceroute-server/src/lib.rs b/rust/crates/traceroute-server/src/lib.rs new file mode 100644 index 00000000..9264dbc2 --- /dev/null +++ b/rust/crates/traceroute-server/src/lib.rs @@ -0,0 +1,10 @@ +//! HTTP REST API server for datadog-traceroute. + +mod handlers; +mod runner; + +pub use handlers::create_router; +pub use runner::run_traceroute; + +/// Default server port (IANA Remote Traceroute). +pub const DEFAULT_PORT: u16 = 3765; diff --git a/rust/crates/traceroute-server/src/main.rs b/rust/crates/traceroute-server/src/main.rs new file mode 100644 index 00000000..1779e057 --- /dev/null +++ b/rust/crates/traceroute-server/src/main.rs @@ -0,0 +1,60 @@ +//! HTTP server binary for datadog-traceroute. + +use clap::Parser; +use std::net::SocketAddr; +use traceroute_server::create_router; + +/// Datadog Traceroute HTTP Server. +#[derive(Parser, Debug)] +#[command(name = "datadog-traceroute-server")] +#[command(version)] +#[command(about = "HTTP REST API server for Datadog Traceroute")] +struct Args { + /// Address to listen on. + #[arg(long, default_value = "0.0.0.0:3765")] + addr: String, + + /// Log level (trace, debug, info, warn, error). + #[arg(long = "log-level", default_value = "info")] + log_level: String, +} + +#[tokio::main] +async fn main() { + let args = Args::parse(); + + // Initialize logging + let filter = match args.log_level.to_lowercase().as_str() { + "trace" => "trace", + "debug" => "debug", + "info" => "info", + "warn" => "warn", + "error" => "error", + _ => "info", + }; + + tracing_subscriber::fmt().with_env_filter(filter).init(); + + let addr: SocketAddr = args.addr.parse().unwrap_or_else(|_| { + eprintln!("Invalid address: {}", args.addr); + std::process::exit(1); + }); + + tracing::info!("Starting HTTP server on {}", addr); + + let router = create_router(); + + let listener = tokio::net::TcpListener::bind(addr) + .await + .unwrap_or_else(|e| { + eprintln!("Failed to bind to {}: {}", addr, e); + std::process::exit(1); + }); + + tracing::info!("HTTP server listening on {}", addr); + + axum::serve(listener, router).await.unwrap_or_else(|e| { + eprintln!("Server error: {}", e); + std::process::exit(1); + }); +} diff --git a/rust/crates/traceroute-server/src/runner.rs b/rust/crates/traceroute-server/src/runner.rs new file mode 100644 index 00000000..42e95461 --- /dev/null +++ b/rust/crates/traceroute-server/src/runner.rs @@ -0,0 +1,229 @@ +//! Traceroute runner for HTTP server. + +use hickory_resolver::TokioAsyncResolver; +use std::net::{IpAddr, SocketAddr}; +use traceroute_core::{ + execution::traceroute_serial, DestinationInfo, ProbeResponse, Protocol, ResultDestination, + Results, SourceInfo, Stats, TracerouteConfig, TracerouteDriver, TracerouteError, TracerouteHop, + TracerouteResults, TracerouteRun, +}; +use traceroute_icmp::IcmpDriver; +use traceroute_packets::new_source_sink; +use traceroute_tcp::TcpDriver; +use traceroute_udp::UdpDriver; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +/// Get the local IP address for connecting to the target. +fn get_local_addr(target: IpAddr) -> Result { + let socket = match target { + IpAddr::V4(_) => std::net::UdpSocket::bind("0.0.0.0:0"), + IpAddr::V6(_) => std::net::UdpSocket::bind("[::]:0"), + } + .map_err(TracerouteError::SocketCreation)?; + + let port = 33434; + socket + .connect(SocketAddr::new(target, port)) + .map_err(TracerouteError::SocketCreation)?; + + socket + .local_addr() + .map(|addr| addr.ip()) + .map_err(TracerouteError::SocketCreation) +} + +/// Allocate a local port. +fn allocate_port(is_v6: bool) -> Result { + let socket = if is_v6 { + std::net::UdpSocket::bind("[::]:0") + } else { + std::net::UdpSocket::bind("0.0.0.0:0") + } + .map_err(TracerouteError::SocketCreation)?; + + socket + .local_addr() + .map(|addr| addr.port()) + .map_err(TracerouteError::SocketCreation) +} + +/// Resolve a hostname to an IP address. +pub async fn resolve_hostname(hostname: &str, want_v6: bool) -> Result { + if let Ok(ip) = hostname.parse::() { + return Ok(ip); + } + + let resolver = TokioAsyncResolver::tokio_from_system_conf() + .map_err(|e| TracerouteError::Internal(format!("Failed to create DNS resolver: {}", e)))?; + + let lookup = resolver.lookup_ip(hostname).await.map_err(|e| { + TracerouteError::Internal(format!("Failed to resolve hostname '{}': {}", hostname, e)) + })?; + + for ip in lookup.iter() { + match (ip, want_v6) { + (IpAddr::V6(_), true) => return Ok(ip), + (IpAddr::V4(_), false) => return Ok(ip), + _ => continue, + } + } + + lookup + .iter() + .next() + .ok_or_else(|| TracerouteError::Internal(format!("No addresses found for '{}'", hostname))) +} + +/// Run a single traceroute. +async fn run_traceroute_once( + config: &TracerouteConfig, + target_ip: IpAddr, + src_ip: IpAddr, + src_port: u16, +) -> Result>, TracerouteError> { + let handle = new_source_sink(target_ip, config.use_windows_driver).await?; + + let mut driver: Box = match config.protocol { + Protocol::Udp => Box::new(UdpDriver::new( + src_ip, + src_port, + target_ip, + config.port, + handle.source, + handle.sink, + )), + Protocol::Tcp => Box::new(TcpDriver::new( + src_ip, + src_port, + target_ip, + config.port, + handle.source, + handle.sink, + true, + config.params.max_ttl, + )), + Protocol::Icmp => Box::new(IcmpDriver::new( + src_ip, + target_ip, + handle.source, + handle.sink, + config.params.min_ttl, + config.params.max_ttl, + )), + }; + + let results = traceroute_serial(driver.as_mut(), &config.params).await?; + driver.close().await?; + + Ok(results) +} + +/// Convert probe responses to TracerouteHop. +fn responses_to_hops(responses: Vec>, min_ttl: u8) -> Vec { + responses + .into_iter() + .enumerate() + .map(|(i, response)| { + let ttl = min_ttl + i as u8; + match response { + Some(probe) => TracerouteHop { + ttl, + ip_address: Some(probe.ip), + rtt: Some(probe.rtt.as_secs_f64() * 1000.0), + reachable: true, + reverse_dns: Vec::new(), + }, + None => TracerouteHop { + ttl, + ip_address: None, + rtt: None, + reachable: false, + reverse_dns: Vec::new(), + }, + } + }) + .collect() +} + +/// Run traceroute with given config. +pub async fn run_traceroute(config: TracerouteConfig) -> Result { + info!( + target = %config.hostname, + protocol = %config.protocol, + port = config.port, + "Starting traceroute" + ); + + let target_ip = resolve_hostname(&config.hostname, config.want_v6).await?; + debug!("Resolved {} to {}", config.hostname, target_ip); + + let src_ip = get_local_addr(target_ip)?; + debug!("Using local IP: {}", src_ip); + + let mut runs = Vec::new(); + let mut errors = Vec::new(); + + for i in 0..config.traceroute_queries { + debug!( + "Running traceroute query {}/{}", + i + 1, + config.traceroute_queries + ); + + let src_port = allocate_port(target_ip.is_ipv6())?; + + match run_traceroute_once(&config, target_ip, src_ip, src_port).await { + Ok(responses) => { + let hops = responses_to_hops(responses, config.params.min_ttl); + + let run = TracerouteRun { + run_id: Uuid::new_v4().to_string(), + source: SourceInfo { + ip_address: Some(src_ip), + port: Some(src_port), + }, + destination: DestinationInfo { + ip_address: Some(target_ip), + port: Some(config.port), + reverse_dns: Vec::new(), + }, + hops, + }; + runs.push(run); + } + Err(e) => { + warn!("Traceroute query {} failed: {}", i + 1, e); + errors.push(e); + } + } + } + + if runs.is_empty() && !errors.is_empty() { + return Err(errors.remove(0)); + } + + let hop_count = if runs.len() > 1 { + let counts: Vec = runs + .iter() + .map(|r| r.hops.iter().filter(|h| h.reachable).count() as f64) + .collect(); + let avg = counts.iter().sum::() / counts.len() as f64; + let min = counts.iter().cloned().fold(f64::INFINITY, f64::min); + let max = counts.iter().cloned().fold(f64::NEG_INFINITY, f64::max); + Some(Stats { avg, min, max }) + } else { + None + }; + + Ok(Results { + protocol: config.protocol.to_string(), + source: None, + destination: ResultDestination { + hostname: config.hostname, + port: config.port, + }, + traceroute: TracerouteResults { runs, hop_count }, + e2e_probe: None, + }) +} diff --git a/rust/crates/traceroute-tcp/Cargo.toml b/rust/crates/traceroute-tcp/Cargo.toml new file mode 100644 index 00000000..1875b9cc --- /dev/null +++ b/rust/crates/traceroute-tcp/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "traceroute-tcp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "TCP SYN traceroute implementation" + +[features] +default = [] +paris-mode = [] # Enable Paris traceroute mode + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +pnet = { workspace = true } +pnet_packet = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-tcp/src/driver.rs b/rust/crates/traceroute-tcp/src/driver.rs new file mode 100644 index 00000000..83b91640 --- /dev/null +++ b/rust/crates/traceroute-tcp/src/driver.rs @@ -0,0 +1,432 @@ +//! TCP SYN traceroute driver implementation. + +use crate::packet::{create_tcp_syn_packet, PARIS_PACKET_ID}; +use async_trait::async_trait; +use rand::Rng; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use traceroute_core::{ProbeResponse, TracerouteDriver, TracerouteDriverInfo, TracerouteError}; +use traceroute_packets::{parse_tcp_first_bytes, FrameParser, Sink, Source}; +use tracing::{debug, trace, warn}; + +/// Base packet ID for non-Paris mode (same as Go implementation). +const BASE_PACKET_ID: u16 = 41821; + +/// Data stored for each sent probe. +#[derive(Debug, Clone)] +struct ProbeData { + send_time: Instant, + ttl: u8, + packet_id: u16, + seq_num: u32, +} + +/// TCP SYN traceroute driver. +pub struct TcpDriver { + /// Source IP address. + src_ip: IpAddr, + /// Source port. + src_port: u16, + /// Target IP address. + target_ip: IpAddr, + /// Target port. + target_port: u16, + /// Packet source for receiving. + source: Box, + /// Packet sink for sending. + sink: Box, + /// Read buffer. + buffer: Vec, + /// Frame parser. + parser: FrameParser, + /// List of sent probes for matching responses. + sent_probes: Mutex>, + /// Whether to use Paris traceroute mode. + paris_mode: bool, + /// Base packet ID for non-Paris mode. + base_packet_id: u16, + /// Base sequence number for non-Paris mode. + base_seq_num: u32, + /// Whether to loosen ICMP source checking. + loosen_icmp_src: bool, + /// Maximum TTL (for packet ID allocation). + #[allow(dead_code)] + max_ttl: u8, +} + +impl TcpDriver { + /// Creates a new TCP driver. + #[allow(clippy::too_many_arguments)] + pub fn new( + src_ip: IpAddr, + src_port: u16, + target_ip: IpAddr, + target_port: u16, + source: Box, + sink: Box, + paris_mode: bool, + max_ttl: u8, + ) -> Self { + let mut rng = rand::thread_rng(); + + // In non-Paris mode: fixed seq number, packet ID varies with TTL + // In Paris mode: fixed packet ID (41821), random seq per packet + let (base_packet_id, base_seq_num) = if paris_mode { + (PARIS_PACKET_ID, 0) + } else { + // Allocate packet IDs from a base that won't overflow + let base_id = BASE_PACKET_ID; + let seq = rng.gen::(); + (base_id, seq) + }; + + Self { + src_ip, + src_port, + target_ip, + target_port, + source, + sink, + buffer: vec![0u8; 1500], + parser: FrameParser::new(), + sent_probes: Mutex::new(Vec::new()), + paris_mode, + base_packet_id, + base_seq_num, + loosen_icmp_src: false, + max_ttl, + } + } + + /// Sets whether to loosen ICMP source checking. + /// + /// Some environments don't properly translate the payload of an ICMP TTL exceeded + /// packet, meaning you can't trust the source address to correspond to your own private IP. + pub fn set_loosen_icmp_src(&mut self, loosen: bool) { + self.loosen_icmp_src = loosen; + } + + fn store_probe(&self, data: ProbeData) { + let mut probes = self.sent_probes.lock().unwrap(); + probes.push(data); + } + + fn find_matching_probe(&self, packet_id: u16, seq_num: u32) -> Option { + let probes = self.sent_probes.lock().unwrap(); + probes + .iter() + .find(|p| p.packet_id == packet_id && p.seq_num == seq_num) + .cloned() + } + + fn get_last_sent_probe(&self) -> Option { + let probes = self.sent_probes.lock().unwrap(); + probes.last().cloned() + } + + fn get_next_packet_id_and_seq(&self, ttl: u8) -> (u16, u32) { + if self.paris_mode { + // Paris mode: fixed packet ID, random seq per packet + let seq = rand::thread_rng().gen::(); + (PARIS_PACKET_ID, seq) + } else { + // Regular mode: packet ID varies with TTL, fixed seq + (self.base_packet_id + ttl as u16, self.base_seq_num) + } + } + + fn get_local_addr(&self) -> SocketAddr { + SocketAddr::new(self.src_ip, self.src_port) + } + + fn get_target_addr(&self) -> SocketAddr { + SocketAddr::new(self.target_ip, self.target_port) + } + + async fn handle_probe_layers(&mut self) -> Result, TracerouteError> { + let ip_pair = self.parser.get_ip_pair(); + + // Check if this is a TCP response (SYN/ACK or RST from destination) + if self.parser.is_tcp() { + return self.handle_tcp_response(&ip_pair).await; + } + + // Check if this is an ICMP TTL exceeded response + if self.parser.is_icmp() && self.parser.is_ttl_exceeded() { + return self.handle_icmp_response(&ip_pair).await; + } + + Err(TracerouteError::PacketMismatch) + } + + async fn handle_tcp_response( + &mut self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + let is_syn_ack = self.parser.is_syn_ack; + let is_rst = self.parser.is_rst; + + // We only care about SYN/ACK and RST responses + if !is_syn_ack && !is_rst { + return Err(TracerouteError::PacketMismatch); + } + + // Check IP pair (should be from target to us) + let expected_src = self.target_ip; + let expected_dst = self.src_ip; + + if ip_pair.src_addr != Some(expected_src) || ip_pair.dst_addr != Some(expected_dst) { + trace!( + expected_src = %expected_src, + expected_dst = %expected_dst, + actual_src = ?ip_pair.src_addr, + actual_dst = ?ip_pair.dst_addr, + "Ignored TCP packet with different IP pair" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Check ports + let tcp_info = self + .parser + .tcp_info + .ok_or_else(|| TracerouteError::MalformedPacket("Missing TCP info".to_string()))?; + + if tcp_info.src_port != self.target_port { + trace!( + expected = self.target_port, + actual = tcp_info.src_port, + "Ignored TCP packet with different source port" + ); + return Err(TracerouteError::PacketMismatch); + } + + if tcp_info.dst_port != self.src_port { + trace!( + expected = self.src_port, + actual = tcp_info.dst_port, + "Ignored TCP packet with different destination port" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Get last sent probe to match sequence number + let last_probe = self + .get_last_sent_probe() + .ok_or_else(|| TracerouteError::Internal("No probes sent yet".to_string()))?; + + // For SYN/ACK, the ACK number should be our SEQ + 1 + // So our sent SEQ should be ACK - 1 + // Note: We don't have access to ACK number in the current TcpInfo struct + // For now, we'll match based on timing (last probe sent) + + let rtt = last_probe.send_time.elapsed(); + let src_addr = ip_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + + Ok(Some(ProbeResponse { + ttl: last_probe.ttl, + ip: src_addr, + rtt, + is_dest: true, // TCP responses are always from destination + })) + } + + async fn handle_icmp_response( + &mut self, + ip_pair: &traceroute_packets::IpPair, + ) -> Result, TracerouteError> { + let icmp_info = self + .parser + .get_icmp_info() + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP info".to_string()))?; + + // Parse TCP info from ICMP payload + let tcp_info = parse_tcp_first_bytes(&icmp_info.payload).map_err(|e| { + TracerouteError::MalformedPacket(format!("Failed to parse TCP info: {}", e)) + })?; + + // Check source/destination match + let icmp_src = SocketAddr::new( + icmp_info + .icmp_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + tcp_info.src_port, + ); + let icmp_dst = SocketAddr::new( + icmp_info + .icmp_pair + .dst_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + tcp_info.dst_port, + ); + let local = self.get_local_addr(); + let target = self.get_target_addr(); + + if icmp_dst.ip() != target.ip() || icmp_dst.port() != target.port() { + trace!( + expected = %target, + actual = %icmp_dst, + "Ignored ICMP packet with different destination" + ); + return Err(TracerouteError::PacketMismatch); + } + + if !self.loosen_icmp_src && (icmp_src.ip() != local.ip() || icmp_src.port() != local.port()) + { + trace!( + expected = %local, + actual = %icmp_src, + "Ignored ICMP packet with different source" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Find matching probe by packet ID and sequence number + let probe = match self.find_matching_probe(icmp_info.wrapped_packet_id, tcp_info.seq) { + Some(p) => p, + None => { + warn!( + packet_id = icmp_info.wrapped_packet_id, + seq = tcp_info.seq, + "Couldn't find probe matching packet ID and seq" + ); + return Err(TracerouteError::PacketMismatch); + } + }; + + let rtt = probe.send_time.elapsed(); + let src_addr = ip_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + let is_dest = src_addr == self.target_ip; + + Ok(Some(ProbeResponse { + ttl: probe.ttl, + ip: src_addr, + rtt, + is_dest, + })) + } +} + +#[async_trait] +impl TracerouteDriver for TcpDriver { + fn get_driver_info(&self) -> TracerouteDriverInfo { + TracerouteDriverInfo { + // TCP SYN driver does not support parallel mode due to + // how responses are matched (SYN/ACK doesn't include original packet ID) + supports_parallel: false, + } + } + + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError> { + let (packet_id, seq_num) = self.get_next_packet_id_and_seq(ttl); + + let (packet, checksum) = create_tcp_syn_packet( + self.src_ip, + self.target_ip, + self.src_port, + self.target_port, + ttl, + seq_num, + packet_id, + )?; + + let data = ProbeData { + send_time: Instant::now(), + ttl, + packet_id, + seq_num, + }; + + trace!( + ttl = ttl, + packet_id = packet_id, + seq_num = seq_num, + checksum = checksum, + paris_mode = self.paris_mode, + "Sending TCP SYN probe" + ); + + self.store_probe(data); + + self.sink.write_to(&packet, self.get_target_addr()).await?; + + Ok(()) + } + + async fn receive_probe( + &mut self, + timeout: Duration, + ) -> Result, TracerouteError> { + let deadline = Instant::now() + timeout; + self.source.set_read_deadline(deadline)?; + + let n = self.source.read(&mut self.buffer).await?; + + if let Err(e) = self.parser.parse(&self.buffer[..n]) { + debug!(error = %e, "Failed to parse packet"); + return Err(e); + } + + self.handle_probe_layers().await + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + let sink_result = self.sink.close().await; + let source_result = self.source.close().await; + + sink_result?; + source_result?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_driver_info() { + let info = TracerouteDriverInfo { + supports_parallel: false, + }; + assert!(!info.supports_parallel); + } + + #[test] + fn test_packet_id_generation_paris_mode() { + // In Paris mode, packet ID should always be PARIS_PACKET_ID + // and seq should be random + let paris_mode = true; + + // Simulate multiple calls + for _ in 0..10 { + let (packet_id, _seq) = if paris_mode { + let seq = rand::thread_rng().gen::(); + (PARIS_PACKET_ID, seq) + } else { + unreachable!() + }; + assert_eq!(packet_id, PARIS_PACKET_ID); + } + } + + #[test] + fn test_packet_id_generation_regular_mode() { + // In regular mode, packet ID varies with TTL + let base_packet_id = BASE_PACKET_ID; + let base_seq_num = 0x12345678u32; + + for ttl in 1..=30 { + let (packet_id, seq) = (base_packet_id + ttl as u16, base_seq_num); + assert_eq!(packet_id, BASE_PACKET_ID + ttl as u16); + assert_eq!(seq, base_seq_num); + } + } +} diff --git a/rust/crates/traceroute-tcp/src/lib.rs b/rust/crates/traceroute-tcp/src/lib.rs new file mode 100644 index 00000000..562187fb --- /dev/null +++ b/rust/crates/traceroute-tcp/src/lib.rs @@ -0,0 +1,7 @@ +//! TCP SYN traceroute implementation. + +mod driver; +mod packet; + +pub use driver::TcpDriver; +pub use packet::{create_tcp_syn_packet, PARIS_PACKET_ID}; diff --git a/rust/crates/traceroute-tcp/src/packet.rs b/rust/crates/traceroute-tcp/src/packet.rs new file mode 100644 index 00000000..0419774e --- /dev/null +++ b/rust/crates/traceroute-tcp/src/packet.rs @@ -0,0 +1,190 @@ +//! TCP packet construction using pnet. + +use pnet_packet::ip::IpNextHeaderProtocols; +use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet}; +use pnet_packet::tcp::{MutableTcpPacket, TcpFlags}; +use std::net::{IpAddr, Ipv4Addr}; +use traceroute_core::TracerouteError; + +/// Base packet ID for Paris traceroute mode. +pub const PARIS_PACKET_ID: u16 = 41821; + +/// TCP window size used in SYN packets. +const TCP_WINDOW_SIZE: u16 = 1024; + +/// Creates a TCP SYN packet for traceroute. +/// +/// Returns (packet_bytes, checksum). +pub fn create_tcp_syn_packet( + src_ip: IpAddr, + dst_ip: IpAddr, + src_port: u16, + dst_port: u16, + ttl: u8, + seq_num: u32, + packet_id: u16, +) -> Result<(Vec, u16), TracerouteError> { + match (src_ip, dst_ip) { + (IpAddr::V4(src), IpAddr::V4(dst)) => { + create_tcp_syn_packet_v4(src, dst, src_port, dst_port, ttl, seq_num, packet_id) + } + (IpAddr::V6(_src), IpAddr::V6(_dst)) => { + // TODO: Implement IPv6 + Err(TracerouteError::Internal( + "IPv6 not yet implemented".to_string(), + )) + } + _ => Err(TracerouteError::Internal( + "IP version mismatch between source and destination".to_string(), + )), + } +} + +fn create_tcp_syn_packet_v4( + src_ip: Ipv4Addr, + dst_ip: Ipv4Addr, + src_port: u16, + dst_port: u16, + ttl: u8, + seq_num: u32, + packet_id: u16, +) -> Result<(Vec, u16), TracerouteError> { + // Calculate sizes + let tcp_len = 20; // TCP header without options + let ip_len = 20 + tcp_len; // IP header (20) + TCP header + + // Allocate buffer for the entire packet + let mut buffer = vec![0u8; ip_len]; + + // Create IPv4 packet + let mut ip_packet = MutableIpv4Packet::new(&mut buffer) + .ok_or_else(|| TracerouteError::Internal("Failed to create IP packet".to_string()))?; + + ip_packet.set_version(4); + ip_packet.set_header_length(5); // 5 * 4 = 20 bytes + ip_packet.set_total_length(ip_len as u16); + ip_packet.set_identification(packet_id); + ip_packet.set_flags(Ipv4Flags::DontFragment); + ip_packet.set_ttl(ttl); + ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Tcp); + ip_packet.set_source(src_ip); + ip_packet.set_destination(dst_ip); + + // Calculate IP checksum + let ip_checksum = pnet_packet::ipv4::checksum(&ip_packet.to_immutable()); + ip_packet.set_checksum(ip_checksum); + + // Create TCP packet in the payload section + let tcp_start = 20; // After IP header + { + let mut tcp_packet = MutableTcpPacket::new(&mut buffer[tcp_start..]) + .ok_or_else(|| TracerouteError::Internal("Failed to create TCP packet".to_string()))?; + + tcp_packet.set_source(src_port); + tcp_packet.set_destination(dst_port); + tcp_packet.set_sequence(seq_num); + tcp_packet.set_acknowledgement(0); + tcp_packet.set_data_offset(5); // 5 * 4 = 20 bytes (no options) + tcp_packet.set_flags(TcpFlags::SYN); + tcp_packet.set_window(TCP_WINDOW_SIZE); + tcp_packet.set_urgent_ptr(0); + + // Calculate TCP checksum + let tcp_checksum = + pnet_packet::tcp::ipv4_checksum(&tcp_packet.to_immutable(), &src_ip, &dst_ip); + tcp_packet.set_checksum(tcp_checksum); + } + + // Read back the checksum + let tcp_checksum = u16::from_be_bytes([buffer[tcp_start + 16], buffer[tcp_start + 17]]); + + Ok((buffer, tcp_checksum)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_tcp_syn_packet() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + let result = create_tcp_syn_packet( + IpAddr::V4(src_ip), + IpAddr::V4(dst_ip), + 12345, + 80, + 5, + 0x12345678, + PARIS_PACKET_ID, + ); + + assert!(result.is_ok()); + let (packet, _checksum) = result.unwrap(); + + // Check packet length (20 IP + 20 TCP = 40) + assert_eq!(packet.len(), 40); + + // Check IP version + assert_eq!(packet[0] >> 4, 4); + + // Check TTL + assert_eq!(packet[8], 5); + + // Check protocol (TCP = 6) + assert_eq!(packet[9], 6); + + // Check TCP flags (SYN = 0x02) + assert_eq!(packet[33] & 0x3f, 0x02); + } + + #[test] + fn test_tcp_seq_number() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + let seq_num: u32 = 0xDEADBEEF; + + let (packet, _) = create_tcp_syn_packet( + IpAddr::V4(src_ip), + IpAddr::V4(dst_ip), + 12345, + 80, + 5, + seq_num, + PARIS_PACKET_ID, + ) + .unwrap(); + + // Sequence number is at TCP offset + 4 (bytes 24-27 in the full packet) + let tcp_start = 20; + let parsed_seq = u32::from_be_bytes([ + packet[tcp_start + 4], + packet[tcp_start + 5], + packet[tcp_start + 6], + packet[tcp_start + 7], + ]); + assert_eq!(parsed_seq, seq_num); + } + + #[test] + fn test_paris_packet_id() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + let (packet, _) = create_tcp_syn_packet( + IpAddr::V4(src_ip), + IpAddr::V4(dst_ip), + 12345, + 80, + 5, + 0, + PARIS_PACKET_ID, + ) + .unwrap(); + + // Packet ID is at IP offset + 4 (bytes 4-5) + let parsed_id = u16::from_be_bytes([packet[4], packet[5]]); + assert_eq!(parsed_id, PARIS_PACKET_ID); + } +} diff --git a/rust/crates/traceroute-udp/Cargo.toml b/rust/crates/traceroute-udp/Cargo.toml new file mode 100644 index 00000000..d799f697 --- /dev/null +++ b/rust/crates/traceroute-udp/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "traceroute-udp" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "UDP traceroute implementation" + +[dependencies] +traceroute-core = { workspace = true } +traceroute-packets = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +pnet = { workspace = true } +pnet_packet = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +rand = { workspace = true } + +[dev-dependencies] +mockall = { workspace = true } diff --git a/rust/crates/traceroute-udp/src/driver.rs b/rust/crates/traceroute-udp/src/driver.rs new file mode 100644 index 00000000..28a23b6a --- /dev/null +++ b/rust/crates/traceroute-udp/src/driver.rs @@ -0,0 +1,271 @@ +//! UDP traceroute driver implementation. + +use crate::packet::create_udp_packet; +use async_trait::async_trait; +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use traceroute_core::{ProbeResponse, TracerouteDriver, TracerouteDriverInfo, TracerouteError}; +use traceroute_packets::{parse_udp_first_bytes, FrameParser, Sink, Source}; +use tracing::{debug, trace, warn}; + +/// Data stored for each sent probe. +#[derive(Debug, Clone)] +struct ProbeData { + send_time: Instant, + ttl: u8, +} + +/// UDP traceroute driver. +pub struct UdpDriver { + /// Source IP address. + src_ip: IpAddr, + /// Source port. + src_port: u16, + /// Target IP address. + target_ip: IpAddr, + /// Target port. + target_port: u16, + /// Packet source for receiving. + source: Box, + /// Packet sink for sending. + sink: Box, + /// Read buffer. + buffer: Vec, + /// Frame parser. + parser: FrameParser, + /// Map of packet ID to probe data. + sent_probes: Mutex>, + /// Whether to loosen ICMP source checking. + loosen_icmp_src: bool, +} + +impl UdpDriver { + /// Creates a new UDP driver. + pub fn new( + src_ip: IpAddr, + src_port: u16, + target_ip: IpAddr, + target_port: u16, + source: Box, + sink: Box, + ) -> Self { + Self { + src_ip, + src_port, + target_ip, + target_port, + source, + sink, + buffer: vec![0u8; 1500], + parser: FrameParser::new(), + sent_probes: Mutex::new(HashMap::new()), + loosen_icmp_src: false, + } + } + + /// Sets whether to loosen ICMP source checking. + /// + /// Some environments don't properly translate the payload of an ICMP TTL exceeded + /// packet, meaning you can't trust the source address to correspond to your own private IP. + pub fn set_loosen_icmp_src(&mut self, loosen: bool) { + self.loosen_icmp_src = loosen; + } + + fn store_probe(&self, packet_id: u16, data: ProbeData) -> bool { + let mut probes = self.sent_probes.lock().unwrap(); + + // Refuse to store if we would overwrite + if probes.contains_key(&packet_id) { + return false; + } + + probes.insert(packet_id, data); + true + } + + fn find_matching_probe(&self, packet_id: u16) -> Option { + let probes = self.sent_probes.lock().unwrap(); + probes.get(&packet_id).cloned() + } + + fn get_local_addr(&self) -> SocketAddr { + SocketAddr::new(self.src_ip, self.src_port) + } + + fn get_target_addr(&self) -> SocketAddr { + SocketAddr::new(self.target_ip, self.target_port) + } + + async fn handle_probe_layers(&mut self) -> Result, TracerouteError> { + let ip_pair = self.parser.get_ip_pair(); + + // We only care about ICMP responses for UDP traceroute + if !self.parser.is_icmp() { + return Err(TracerouteError::PacketMismatch); + } + + // Must be TTL exceeded or destination unreachable + if !self.parser.is_ttl_exceeded() && !self.parser.is_dest_unreachable() { + return Err(TracerouteError::PacketMismatch); + } + + let icmp_info = self + .parser + .get_icmp_info() + .ok_or_else(|| TracerouteError::MalformedPacket("Missing ICMP info".to_string()))?; + + // Parse UDP info from ICMP payload + let udp_info = parse_udp_first_bytes(&icmp_info.payload).map_err(|e| { + TracerouteError::MalformedPacket(format!("Failed to parse UDP info: {}", e)) + })?; + + // Check source/destination match + let icmp_src = SocketAddr::new( + icmp_info + .icmp_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + udp_info.src_port, + ); + let icmp_dst = SocketAddr::new( + icmp_info + .icmp_pair + .dst_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)), + udp_info.dst_port, + ); + let local = self.get_local_addr(); + let target = self.get_target_addr(); + + if icmp_dst.ip() != target.ip() || icmp_dst.port() != target.port() { + trace!( + expected = %target, + actual = %icmp_dst, + "Ignored ICMP packet with different destination" + ); + return Err(TracerouteError::PacketMismatch); + } + + if !self.loosen_icmp_src && (icmp_src.ip() != local.ip() || icmp_src.port() != local.port()) + { + trace!( + expected = %local, + actual = %icmp_src, + "Ignored ICMP packet with different source" + ); + return Err(TracerouteError::PacketMismatch); + } + + // Find matching probe by packet ID + let packet_id = icmp_info.wrapped_packet_id; + let probe = match self.find_matching_probe(packet_id) { + Some(p) => p, + None => { + warn!( + packet_id = packet_id, + "Couldn't find probe matching packet ID" + ); + return Err(TracerouteError::PacketMismatch); + } + }; + + let rtt = probe.send_time.elapsed(); + let src_addr = ip_pair + .src_addr + .unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + let is_dest = src_addr == self.target_ip; + + Ok(Some(ProbeResponse { + ttl: probe.ttl, + ip: src_addr, + rtt, + is_dest, + })) + } +} + +#[async_trait] +impl TracerouteDriver for UdpDriver { + fn get_driver_info(&self) -> TracerouteDriverInfo { + TracerouteDriverInfo { + supports_parallel: true, + } + } + + async fn send_probe(&mut self, ttl: u8) -> Result<(), TracerouteError> { + let (packet_id, packet, checksum) = create_udp_packet( + self.src_ip, + self.target_ip, + self.src_port, + self.target_port, + ttl, + )?; + + let data = ProbeData { + send_time: Instant::now(), + ttl, + }; + + trace!( + ttl = ttl, + packet_id = packet_id, + checksum = checksum, + "Sending UDP probe" + ); + + if !self.store_probe(packet_id, data) { + return Err(TracerouteError::Internal(format!( + "Tried to send the same probe ID twice for TTL={}", + ttl + ))); + } + + self.sink.write_to(&packet, self.get_target_addr()).await?; + + Ok(()) + } + + async fn receive_probe( + &mut self, + timeout: Duration, + ) -> Result, TracerouteError> { + let deadline = Instant::now() + timeout; + self.source.set_read_deadline(deadline)?; + + let n = self.source.read(&mut self.buffer).await?; + + if let Err(e) = self.parser.parse(&self.buffer[..n]) { + debug!(error = %e, "Failed to parse packet"); + return Err(e); + } + + self.handle_probe_layers().await + } + + async fn close(&mut self) -> Result<(), TracerouteError> { + let sink_result = self.sink.close().await; + let source_result = self.source.close().await; + + sink_result?; + source_result?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_driver_info() { + // Note: We can't easily test the full driver without mocking + // This just tests the driver info + let info = TracerouteDriverInfo { + supports_parallel: true, + }; + assert!(info.supports_parallel); + } +} diff --git a/rust/crates/traceroute-udp/src/lib.rs b/rust/crates/traceroute-udp/src/lib.rs new file mode 100644 index 00000000..a6950047 --- /dev/null +++ b/rust/crates/traceroute-udp/src/lib.rs @@ -0,0 +1,7 @@ +//! UDP traceroute implementation. + +mod driver; +mod packet; + +pub use driver::UdpDriver; +pub use packet::{create_udp_packet, MAGIC_PAYLOAD}; diff --git a/rust/crates/traceroute-udp/src/packet.rs b/rust/crates/traceroute-udp/src/packet.rs new file mode 100644 index 00000000..9893d13e --- /dev/null +++ b/rust/crates/traceroute-udp/src/packet.rs @@ -0,0 +1,150 @@ +//! UDP packet construction using pnet. + +use pnet_packet::ip::IpNextHeaderProtocols; +use pnet_packet::ipv4::{Ipv4Flags, MutableIpv4Packet}; +use pnet_packet::udp::MutableUdpPacket; +use std::net::{IpAddr, Ipv4Addr}; +use traceroute_core::TracerouteError; + +/// Magic payload used in UDP traceroute packets. +pub const MAGIC_PAYLOAD: &[u8] = b"NSMNC"; + +/// Base packet ID (same as Go implementation). +const BASE_PACKET_ID: u16 = 41821; + +/// Creates a UDP packet for traceroute. +/// +/// Returns (packet_id, packet_bytes, checksum). +pub fn create_udp_packet( + src_ip: IpAddr, + dst_ip: IpAddr, + src_port: u16, + dst_port: u16, + ttl: u8, +) -> Result<(u16, Vec, u16), TracerouteError> { + match (src_ip, dst_ip) { + (IpAddr::V4(src), IpAddr::V4(dst)) => { + create_udp_packet_v4(src, dst, src_port, dst_port, ttl) + } + (IpAddr::V6(_src), IpAddr::V6(_dst)) => { + // TODO: Implement IPv6 + Err(TracerouteError::Internal( + "IPv6 not yet implemented".to_string(), + )) + } + _ => Err(TracerouteError::Internal( + "IP version mismatch between source and destination".to_string(), + )), + } +} + +fn create_udp_packet_v4( + src_ip: Ipv4Addr, + dst_ip: Ipv4Addr, + src_port: u16, + dst_port: u16, + ttl: u8, +) -> Result<(u16, Vec, u16), TracerouteError> { + // Packet ID based on TTL (same as Go implementation) + let packet_id = BASE_PACKET_ID + ttl as u16; + + // UDP payload: "NSMNC" + 3 bytes (last 2 bytes contain packet ID) + let mut udp_payload = vec![0u8; 8]; + udp_payload[..5].copy_from_slice(MAGIC_PAYLOAD); + udp_payload[6] = (packet_id >> 8) as u8; + udp_payload[7] = (packet_id & 0xff) as u8; + + // Calculate sizes + let udp_len = 8 + udp_payload.len(); // UDP header (8) + payload + let ip_len = 20 + udp_len; // IP header (20) + UDP packet + + // Allocate buffer for the entire packet + let mut buffer = vec![0u8; ip_len]; + + // Create IPv4 packet + let mut ip_packet = MutableIpv4Packet::new(&mut buffer) + .ok_or_else(|| TracerouteError::Internal("Failed to create IP packet".to_string()))?; + + ip_packet.set_version(4); + ip_packet.set_header_length(5); // 5 * 4 = 20 bytes + ip_packet.set_total_length(ip_len as u16); + ip_packet.set_identification(packet_id); + ip_packet.set_flags(Ipv4Flags::DontFragment); + ip_packet.set_ttl(ttl); + ip_packet.set_next_level_protocol(IpNextHeaderProtocols::Udp); + ip_packet.set_source(src_ip); + ip_packet.set_destination(dst_ip); + + // Calculate IP checksum + let checksum = pnet_packet::ipv4::checksum(&ip_packet.to_immutable()); + ip_packet.set_checksum(checksum); + + // Create UDP packet in the payload section + let udp_start = 20; // After IP header + { + let mut udp_packet = MutableUdpPacket::new(&mut buffer[udp_start..]) + .ok_or_else(|| TracerouteError::Internal("Failed to create UDP packet".to_string()))?; + + udp_packet.set_source(src_port); + udp_packet.set_destination(dst_port); + udp_packet.set_length(udp_len as u16); + + // Set payload + udp_packet.set_payload(&udp_payload); + + // Calculate UDP checksum + let udp_checksum = + pnet_packet::udp::ipv4_checksum(&udp_packet.to_immutable(), &src_ip, &dst_ip); + udp_packet.set_checksum(udp_checksum); + } + + // Read back the checksum + let udp_checksum = u16::from_be_bytes([buffer[udp_start + 6], buffer[udp_start + 7]]); + + Ok((packet_id, buffer, udp_checksum)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_udp_packet() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + let result = create_udp_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), 12345, 33434, 5); + + assert!(result.is_ok()); + let (packet_id, packet, _checksum) = result.unwrap(); + + // Check packet ID + assert_eq!(packet_id, BASE_PACKET_ID + 5); + + // Check packet length (20 IP + 8 UDP header + 8 payload = 36) + assert_eq!(packet.len(), 36); + + // Check IP version + assert_eq!(packet[0] >> 4, 4); + + // Check TTL + assert_eq!(packet[8], 5); + + // Check protocol (UDP = 17) + assert_eq!(packet[9], 17); + } + + #[test] + fn test_packet_id_varies_with_ttl() { + let src_ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + let dst_ip: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + let (id1, _, _) = + create_udp_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), 12345, 33434, 1).unwrap(); + + let (id2, _, _) = + create_udp_packet(IpAddr::V4(src_ip), IpAddr::V4(dst_ip), 12345, 33434, 2).unwrap(); + + assert_eq!(id2 - id1, 1); + } +}