From ed3795e55f1d1ccf2fe1f71787fc9e7c3a3fb7da Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 30 May 2026 05:43:17 -0300 Subject: [PATCH 1/2] feat: production-readiness for open-source launch (GitHub + crates.io) Hardens fleet-router across correctness, robustness, operability, packaging, CI/CD, security, and documentation for a public release. Reliability & correctness: - Atomic, underflow-safe connection counter; ConnectionGuard decrements synchronously on drop (no spawn, no runtime-handle dependency). - Saturating block-number arithmetic (no overflow/panic on upstream data). - Failover no longer silently drops client frames: buffers and replays them after the socket swap; forwards status/keep-alive frames during failover so clients never stall. - Upstream ABI handshake retries against another upstream and skips control frames instead of dropping the client. - Non-advancing upstreams are flagged stale and deprioritized for routing. Operability: - Structured logging via tracing (RUST_LOG; default info). - SIGTERM + SIGINT graceful shutdown with bounded drain and client close frames. - Config validation with actionable errors; `config init` no longer panics. - Connection cap, handshake/idle timeouts, configurable max message size. - Optional /health, /ready, /metrics (Prometheus) HTTP endpoint. Packaging & release: - crates.io metadata, MSRV 1.85, single-line description, lean `include` list, committed Cargo.lock, CLI version from CARGO_PKG_VERSION. - mock-ship marked publish=false. - CI installs clang/libclang, caches deps, runs clippy -D warnings, an MSRV check, cargo-deny, and a Docker build; least-privilege permissions. - Tag-driven release workflow (crates.io + Linux binary + GHCR image). - Hardened Dockerfile (non-root user, HEALTHCHECK, --locked, rust 1.85). Docs & community: - Rewritten README (prerequisites, architecture, full config reference, observability, deployment, security & limitations). - CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, CHANGELOG, issue/PR templates. Tests: added operational tests (config validation + metrics endpoint); made the range-aware routing tests deterministic. Verified on Linux (Docker): cargo fmt --check, clippy --locked -D warnings, full test suite, release build, MSRV 1.85, cargo-deny, cargo publish --dry-run. --- .github/ISSUE_TEMPLATE/bug_report.yml | 112 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 43 + .github/PULL_REQUEST_TEMPLATE.md | 15 + .github/workflows/cargo-build.yml | 24 - .github/workflows/ci.yml | 86 +- .github/workflows/release.yml | 72 + .gitignore | 6 +- CHANGELOG.md | 66 + CODE_OF_CONDUCT.md | 134 ++ CONTRIBUTING.md | 155 ++ Cargo.lock | 2514 ++++++++++++++++++++ Cargo.toml | 21 +- Dockerfile | 17 +- README.md | 274 ++- SECURITY.md | 108 + deny.toml | 31 + docker/docker-compose.test.yml | 2 +- docker/fleet-router.json | 2 +- mock-ship/Cargo.toml | 2 + mock-ship/src/protocol.rs | 1 - mock-ship/src/server.rs | 20 +- src/connection_handler.rs | 567 +++-- src/functions.rs | 74 +- src/health.rs | 184 ++ src/main.rs | 272 ++- src/models.rs | 157 +- src/tasks.rs | 279 +-- src/zcd.rs | 12 +- tests/e2e_proxy.rs | 23 +- tests/operational.rs | 192 ++ tests/stress_test.rs | 28 +- 32 files changed, 4851 insertions(+), 650 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md delete mode 100644 .github/workflows/cargo-build.yml create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 SECURITY.md create mode 100644 deny.toml create mode 100644 src/health.rs create mode 100644 tests/operational.rs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..af93bf4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,112 @@ +name: Bug report +description: Report a problem with fleet-router (proxying, failover, build, config, or metrics). +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. + + Before opening an issue: + - Search existing issues to avoid duplicates. + - For security vulnerabilities, do **not** open a public issue. Use private reporting per [SECURITY.md](../../SECURITY.md). + - For questions or usage help, please use Discussions instead. + + fleet-router currently supports **Linux x86_64 only**. macOS/Windows are not supported for native builds; use the Docker image (`ghcr.io/eosrio/fleet-router`) there. + + - type: input + id: version + attributes: + label: fleet-router version + description: "Output of `fleet-router --version`, or the Docker image tag / git commit you built from." + placeholder: "0.2.0" + validations: + required: true + + - type: dropdown + id: install-method + attributes: + label: How was it installed? + options: + - crates.io (cargo install fleet-router) + - From source (cargo install --path .) + - Docker image (ghcr.io/eosrio/fleet-router) + - Other (describe below) + validations: + required: true + + - type: input + id: os-arch + attributes: + label: OS and architecture + description: "Distribution/version and CPU architecture. Remember: only Linux x86_64 is supported." + placeholder: "Ubuntu 24.04, x86_64" + validations: + required: true + + - type: input + id: build-env + attributes: + label: Rust version and build toolchain (for build issues) + description: >- + Output of `rustc --version` (MSRV is 1.85), and whether clang + libclang-dev were installed + (required by bindgen). Leave blank if this is not a build issue. + placeholder: "rustc 1.85.0; clang/libclang-dev installed: yes" + validations: + required: false + + - type: textarea + id: config + attributes: + label: config.json (redact secrets) + description: "Your config.json or the relevant portion. Redact endpoints/hosts you don't want public." + render: json + placeholder: | + { + "listen_address": "0.0.0.0", + "listen_port": 17000, + "upstream_reconnect_ms": 3000, + "upstream_monitoring_ms": 5000, + "upstream_status_ms": 5000, + "servers": [ + { "name": "node-1", "endpoint": "10.0.0.1:8080", "enabled": true } + ] + } + validations: + required: false + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: "Exact commands and actions that trigger the problem." + placeholder: | + 1. fleet-router config test ./config.json + 2. fleet-router run --config ./config.json + 3. Connect a SHiP client to ws://host:17000 and send a get_blocks request + 4. ... + validations: + required: true + + - type: textarea + id: expected-actual + attributes: + label: Expected vs. actual behavior + description: "What you expected to happen, and what actually happened." + placeholder: | + Expected: client connection persists across upstream failover with no gap in blocks. + Actual: client received duplicate blocks / connection dropped / ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant logs (RUST_LOG=debug) + description: "Run with `RUST_LOG=debug` and paste the relevant output. Redact sensitive values." + render: shell + placeholder: | + RUST_LOG=debug fleet-router run --config ./config.json + 2026-05-30T12:00:00Z INFO fleet_router: ... + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..cfee3aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & usage help + url: https://github.com/eosrio/fleet-router/discussions + about: Ask questions, share deployment setups, and get usage help in GitHub Discussions. + - name: Security vulnerability + url: https://github.com/eosrio/fleet-router/security/advisories/new + about: Do not open a public issue for security problems. Report privately per SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..dce9292 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Suggest a new feature or enhancement for fleet-router. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an improvement. + + Please search existing issues and Discussions first to avoid duplicates. + + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: "What problem are you trying to solve? What is the use case or pain point?" + placeholder: "When an upstream goes stale, I'd like ..." + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: "Describe the behavior or capability you'd like. Be as concrete as you can (config fields, CLI flags, metrics, etc.)." + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: "Other approaches or workarounds you've considered, and why they fall short." + validations: + required: false + + - type: textarea + id: context + attributes: + label: Additional context + description: "Any other context, references, links, or examples that would help." + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3082e02 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +# Summary + + + +## Related issues + + + +## Checklist + +- [ ] Ran `cargo fmt --all` +- [ ] Ran `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] Ran `cargo test --workspace` +- [ ] Updated [CHANGELOG.md](../CHANGELOG.md) +- [ ] Updated docs if needed diff --git a/.github/workflows/cargo-build.yml b/.github/workflows/cargo-build.yml deleted file mode 100644 index 94af6ef..0000000 --- a/.github/workflows/cargo-build.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Rust - -on: - push: - paths: - - 'src/**' - - './Cargo.toml' - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 106a208..86e3ef1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,34 +2,76 @@ name: CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] + +# Least-privilege default token. +permissions: + contents: read env: CARGO_TERM_COLOR: always jobs: - build_and_test: + test: name: Build, Test, and Lint runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Use stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy, rustfmt - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Run Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings - - - name: Build - run: cargo build --verbose - - - name: Run tests - run: cargo test --workspace --verbose + - uses: actions/checkout@v4 + + # rs_abieos builds vendored C++ via bindgen, which needs a C++ toolchain + # and libclang at build time. + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y clang libclang-dev + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings + + - name: Build + run: cargo build --workspace --locked --verbose + + - name: Test + run: cargo test --workspace --locked --verbose + + msrv: + name: Minimum Supported Rust Version (1.85) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y clang libclang-dev + - name: Install Rust 1.85 + uses: dtolnay/rust-toolchain@1.85 + - uses: Swatinem/rust-cache@v2 + - name: Check (MSRV) + run: cargo check --workspace --locked + + audit: + name: Supply-chain audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: cargo-deny (advisories, bans, licenses, sources) + uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories bans licenses sources + + docker: + name: Docker image builds + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build image + run: docker build -t fleet-router:ci . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..832c05d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,72 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: write # create the GitHub release and upload assets + packages: write # push the image to GHCR + +env: + CARGO_TERM_COLOR: always + +jobs: + crates-io: + name: Publish to crates.io + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y clang libclang-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Publish + run: cargo publish -p fleet-router --locked --token ${{ secrets.CARGO_REGISTRY_TOKEN }} + + binary: + name: Build Linux release binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y clang libclang-dev + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --release --locked + - name: Package (x86_64-unknown-linux-gnu) + run: tar -C target/release -czf fleet-router-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz fleet-router + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: fleet-router-*.tar.gz + generate_release_notes: true + + docker: + name: Build and push image to GHCR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/eosrio/fleet-router + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 1d953d4..6838d84 100644 --- a/.gitignore +++ b/.gitignore @@ -3,9 +3,9 @@ debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock +# Cargo.lock IS committed: fleet-router ships a binary, so we pin the exact, +# reproducible dependency graph. (Cargo's guidance: commit it for binaries.) +# https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html # These are backup files generated by rustfmt **/*.rs.bk diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9bb9cdd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,66 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-05-30 + +First public release on crates.io and GitHub. + +### Added + +- Range-aware backend selection: prefer upstreams whose trace range covers the + requested block, falling back to least-connections. +- Automatic failover and reconnection with inline de-duplication of replayed + blocks, so persistent client connections survive upstream outages. +- Structured logging via `tracing`, controllable with the `RUST_LOG` environment + variable (defaults to `info`). +- Graceful shutdown on `SIGINT` and `SIGTERM`, with a bounded drain period + (`shutdown_grace_ms`) and WebSocket close frames sent to connected clients. +- Configuration validation with clear, actionable errors (`config test` and at + startup), including detection of zero intervals and duplicate endpoints. +- Connection backpressure (`max_connections`), client handshake timeout + (`handshake_timeout_ms`), optional idle timeout (`idle_timeout_ms`), and a + configurable maximum WebSocket message size (`max_message_bytes`). +- Optional HTTP health/metrics endpoint (`metrics_port`) exposing `/health`, + `/ready`, and Prometheus `/metrics`. +- Staleness detection: upstreams that stop advancing their chain state are + flagged and deprioritized for routing. +- Capped exponential backoff for upstream monitoring reconnects. +- `cargo-deny` supply-chain configuration and CI (advisories, bans, licenses, + sources), an MSRV check, dependency caching, and a tag-triggered release + workflow (crates.io publish, Linux binary, GHCR image). +- Community health files: `CONTRIBUTING.md`, `SECURITY.md`, + `CODE_OF_CONDUCT.md`, issue templates, and a pull-request template. + +### Changed + +- The CLI `--version` now derives from `Cargo.toml` (`CARGO_PKG_VERSION`). +- The connection counter is now an atomic, decremented synchronously and + underflow-safely when a connection ends. +- Block-number arithmetic uses saturating operations to avoid overflow on + adversarial upstream data. +- During failover, only duplicate block frames are dropped; status results and + head keep-alives are always forwarded so clients never stall. +- Client frames that cannot be forwarded during a failover window are buffered + and replayed after reconnect instead of being silently dropped. +- The published crate now ships only the files needed to build and run the + binary (`include` in `Cargo.toml`); `Cargo.lock` is committed for reproducible + builds. +- Canonical default listen port reconciled to `17000` across the sample config, + Docker assets, and documentation. +- The runtime Docker image runs as a non-root user and declares a `HEALTHCHECK`. + +### Fixed + +- `config init` no longer panics when the target path is not writable; it + reports a clear error instead. +- Upstream handshake now skips leading control frames and retries against + another upstream instead of dropping the client on an unexpected first frame. + +[Unreleased]: https://github.com/eosrio/fleet-router/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/eosrio/fleet-router/releases/tag/v0.2.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f1815ca --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement. For this project, +reports may be made to the EOS Rio maintainers through the repository, using +[GitHub Private Vulnerability Reporting](https://github.com/eosrio/fleet-router/security/advisories/new) +or by otherwise contacting the maintainers via the repository. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7d72b90 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,155 @@ +# Contributing to fleet-router + +Thanks for your interest in improving **fleet-router**, EOS Rio's reverse proxy +and load balancer for the Antelope SHiP (State History Plugin) WebSocket +protocol. Contributions of all kinds are welcome: bug reports, documentation +fixes, and code. + +This guide covers how to set up your environment, build and test the project, +and the checks your pull request must pass before it can be merged. + +Please also read our [Code of Conduct](CODE_OF_CONDUCT.md) — it applies to all +project spaces and interactions. + +## Prerequisites + +fleet-router builds on **Linux x86_64 only**. The `rs_abieos` build script +panics with "Unsupported OS" on macOS and Windows. On those platforms, use the +published Docker image (`ghcr.io/eosrio/fleet-router`) instead of a native +build. + +You need the following to build from source. See the +[Requirements section in the README](README.md#requirements-and-supported-platforms) for the full +rationale. + +- Linux x86_64 +- `git` +- A C/C++ toolchain plus `clang` and `libclang-dev` (the `rs_abieos` build + script compiles vendored C++ and uses `bindgen`, which needs `libclang`) +- Rust **1.85+** (the Minimum Supported Rust Version — a transitive dependency + uses the 2024 edition) + +On Debian/Ubuntu, install the system packages with: + +```bash +sudo apt-get install -y git clang libclang-dev build-essential +``` + +Install Rust via [rustup](https://rustup.rs/) if you do not already have a +toolchain: + +```bash +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +``` + +## Building + +Clone the repository and build the workspace: + +```bash +git clone https://github.com/eosrio/fleet-router.git +cd fleet-router +cargo build +``` + +The first build compiles the vendored C++ in `rs_abieos`, so expect it to take +longer than a typical Rust build. `Cargo.lock` is committed; CI and releases +build with `--locked`, so do not delete or regenerate it unless your change +intentionally updates dependencies. + +## Testing + +Run the workspace test suite: + +```bash +cargo test --workspace +``` + +These tests use the in-repo **mock-ship** test double, so they need **no +external services** — no `nodeos`, no SHiP node, no network access. + +A few integration tests are marked `#[ignore]` because they require a running +Docker stack (real `nodeos` containers and a load generator). They are not part +of the default run. To exercise them, bring up the compose stack under +`docker/` and run the ignored tests explicitly, for example: + +```bash +docker compose -f docker/docker-compose.test.yml up --build -d +cargo test --workspace -- --ignored +``` + +You do **not** need these Docker tests to pass to contribute most changes; the +default `cargo test --workspace` run is sufficient for the great majority of +work. + +## Mandatory pre-PR checks + +CI enforces formatting, linting, and tests, and treats all Clippy warnings as +errors. Run the same checks locally before opening a pull request so your PR +passes on the first try: + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test --workspace +``` + +Notes: + +- `cargo fmt --all` reformats your changes. CI runs `cargo fmt --all -- --check` + and fails if anything is unformatted. +- Clippy must be clean: `-D warnings` turns every warning into an error. +- CI runs these commands with `--locked` and also runs an MSRV check against + Rust 1.85, a `cargo-deny` supply-chain scan (advisories, bans, licenses, + sources), and a Docker image build. Keeping the three commands above green + locally covers the parts you are most likely to break. + +## Updating the CHANGELOG + +This project keeps a [CHANGELOG.md](CHANGELOG.md) in the +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. + +Add an entry for any user-facing change under the `## [Unreleased]` section, +using the appropriate subheading (`Added`, `Changed`, `Fixed`, etc.). Keep +entries concise and written from the user's perspective. Purely internal +refactors with no observable effect do not need an entry. + +## Branches and pull requests + +- Branch off `main` for your work; do not push directly to `main`. +- Keep pull requests **small and focused** — one logical change per PR. Smaller + PRs are easier to review and faster to merge. +- Write **clear, descriptive commit messages** that explain what changed and + why. +- In the PR description, explain the motivation and summarize the change. Link + any related issues. +- Confirm the pre-PR checklist below before requesting review. + +### Pre-PR checklist + +- [ ] `cargo fmt --all` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace` +- [ ] Updated `CHANGELOG.md` under `[Unreleased]` (for user-facing changes) + +## Reporting bugs and requesting features + +Please use the GitHub issue tracker: + +- [Open an issue](https://github.com/eosrio/fleet-router/issues/new/choose) and + pick the appropriate template (bug report or feature request). + +For bug reports, include your OS and architecture, the fleet-router version +(`fleet-router --version`), your configuration (with any secrets redacted), the +steps to reproduce, and the relevant log output. Setting `RUST_LOG=debug` often +makes a report far easier to diagnose. + +## Security + +Do **not** report security vulnerabilities through public issues. Follow the +process in [SECURITY.md](SECURITY.md) for responsible disclosure. + +## License + +By contributing, you agree that your contributions will be licensed under the +project's [MIT License](LICENSE). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6dab983 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2514 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", + "unicase", + "unicode-width", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color-print" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aa954171903797d5623e047d9ab69d91b493657917bdfb8c2c80ecaf9cdb6f4" +dependencies = [ + "color-print-proc-macro", +] + +[[package]] +name = "color-print-proc-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692186b5ebe54007e45a59aea47ece9eb4108e141326c304cdc91699a7118a22" +dependencies = [ + "nom", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fleet-router" +version = "0.2.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "color-print", + "futures 0.3.32", + "mock-ship", + "reqwest", + "rs_abieos", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures 0.1.31", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "libc", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mock-ship" +version = "0.1.0" +dependencies = [ + "futures 0.3.32", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rs_abieos" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323965b3f6a82e210c8c500efc3c61307af25a47e9d0f2a60a79af70e12afcdb" +dependencies = [ + "bindgen", + "cc", + "sys-info", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys-info" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b3a0d0aba8bf96a0e1ddfdc352fc53b3df7f39318c71854910c3c4b024ae52c" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index d45c575..f62c4de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,27 @@ members = [".", "mock-ship"] name = "fleet-router" version = "0.2.0" edition = "2021" -description = """ -Fleet Router - Antelope SHiP Reverse Proxy & Load Balancer -""" +rust-version = "1.85" +description = "Reverse proxy and load balancer for the Antelope SHiP (State History Plugin) WebSocket protocol." authors = ["Igor Lins e Silva", "EOS Rio"] repository = "https://github.com/eosrio/fleet-router" +homepage = "https://github.com/eosrio/fleet-router" +documentation = "https://github.com/eosrio/fleet-router#readme" +readme = "README.md" license = "MIT" categories = ["network-programming"] keywords = ["antelope", "ship", "state-history", "load-balancer", "websockets"] +# Keep the published crate lean: ship only what's needed to build and run the +# binary. Tests, Docker assets, the TypeScript client, and CI config stay in git. +include = [ + "src/**/*.rs", + "Cargo.toml", + "Cargo.lock", + "README.md", + "LICENSE", + "CHANGELOG.md", + "example.config.json", +] [dependencies] anyhow = "1.0.82" @@ -25,6 +38,8 @@ serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" tokio = { version = "1.37.0", features = ["full"] } tokio-tungstenite = "0.28.0" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [dev-dependencies] mock-ship = { path = "mock-ship" } diff --git a/Dockerfile b/Dockerfile index c5a4e13..20dcca5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,23 @@ -# Fleet Router — build from project root -FROM rust:1.82-bookworm AS builder +# Fleet Router — multi-stage build. +# Note: builds on Linux only (rs_abieos compiles vendored C++ via bindgen and +# requires clang/libclang). +FROM rust:1.85-bookworm AS builder RUN apt-get update && \ apt-get install -y --no-install-recommends clang libclang-dev && \ apt-get clean && rm -rf /var/lib/apt/lists/* WORKDIR /build COPY . . -RUN cargo build --release +RUN cargo build --release --locked FROM debian:bookworm-slim RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates && \ - apt-get clean && rm -rf /var/lib/apt/lists/* + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin fleet COPY --from=builder /build/target/release/fleet-router /usr/local/bin/fleet-router -EXPOSE 9000 +USER fleet +EXPOSE 17000 +# Liveness probe: confirm the proxy port is accepting TCP connections. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD bash -c 'exec 3<>/dev/tcp/127.0.0.1/17000' || exit 1 ENTRYPOINT ["fleet-router"] diff --git a/README.md b/README.md index 5db70b0..ba2bf9d 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,82 @@ # fleet-router -![CI](https://github.com/eosrio/fleet-router/actions/workflows/ci.yml/badge.svg) +A reverse proxy and load balancer for the Antelope **SHiP** (State History Plugin) WebSocket protocol. + +[![CI](https://github.com/eosrio/fleet-router/actions/workflows/ci.yml/badge.svg)](https://github.com/eosrio/fleet-router/actions/workflows/ci.yml) [![Crates.io](https://img.shields.io/crates/v/fleet-router.svg)](https://crates.io/crates/fleet-router) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +`fleet-router` sits in front of a fleet of Antelope SHiP nodes. Clients open a single WebSocket connection to the router; the router picks a healthy upstream SHiP node, forwards its ABI, and proxies the WebSocket bidirectionally. If an upstream drops, the router transparently fails over to another suitable node, replays the in-flight `get_blocks` request from the next block, and de-duplicates already-delivered blocks — so the client connection persists without any manual reconnect. + +Use it when you run more than one SHiP node and want clients (Hyperion, dfuse-style indexers, custom consumers) to see a single, resilient endpoint with load balancing and automatic failover, instead of pinning each consumer to one node. + +It is written in Rust on [tokio](https://tokio.rs/) and [tokio-tungstenite](https://github.com/snapview/tokio-tungstenite), and uses [rs_abieos](https://github.com/eosrio/rs-abieos) (C++ FFI) for ABI handling. + +## Features + +- **Range-aware, least-connections load balancing** — prefers an upstream whose trace range covers the requested block; otherwise routes to the least-loaded healthy upstream. +- **Automatic failover with de-duplication** — on upstream loss, reconnects to another suitable node, resumes `get_blocks` at the next block, de-duplicates replayed blocks, and buffers/replays client frames sent during the swap. +- **Stale-upstream deprioritization** — upstreams that stop advancing their chain state are flagged stale and deprioritized (not hard-excluded) when routing. +- **Graceful shutdown** — handles `SIGINT`/`SIGTERM` with a bounded drain and WebSocket close frames to connected clients. +- **Structured logging** — `tracing`-based, controlled with `RUST_LOG`. +- **Optional health/metrics endpoint** — liveness, readiness, and Prometheus metrics over HTTP, enabled on demand. +- **Resource safety** — connection cap, handshake/idle timeouts, and a bounded maximum WebSocket message size. + +## How it works + +A client connects over WebSocket. The router selects an upstream (range-aware, then least-connections), forwards that upstream's ABI to the client, and then proxies frames in both directions. Background loops poll each upstream's status and block progress; an upstream that stops advancing is marked stale and deprioritized. If the active upstream drops, the router selects another suitable upstream, resends the in-flight `get_blocks` request resumed at the next block, de-duplicates blocks the client has already received, and replays any client frames buffered during the swap. The client connection stays open throughout. + +``` + +-----------------------+ + | upstream SHiP node A | (active) + +-----> | ws://hostA:port | + | +-----------------------+ ++--------+ WebSocket +--+-----------+ +| client | ============> | fleet-router | range-aware / least-connections ++--------+ +--+-----------+ selection + health monitoring + | +-----------------------+ + +-----> | upstream SHiP node B | (failover target: + on upstream A failure, | ws://hostB:port | resume at next block, + transparent swap to B ----> +-----------------------+ de-duplicate blocks) +``` -The `fleet-router` is a reverse proxy and load balancer dedicated to the Antelope SHiP protocol. The Fleet SHiP Router is built with Rust using [rs_abieos](https://github.com/eosrio/rs-abieos) for maximum efficiency. +## Requirements and supported platforms -Major Features include: +**Linux x86_64 only.** The `rs_abieos` build script panics with *"Unsupported OS"* on macOS and Windows. On those platforms, use the [Docker image](#docker) instead of a native build. -- **Resilient Connections:** the Fleet SHiP Router maintains persistent client-side connections even when backend servers go offline while there are other backend servers available. This eliminates the need for developers to manually handle reconnections, simplifying application logic. -- **Intelligent Upstream Selection:** The router dynamically routes requests to the most appropriate SHiP server based on factors like data availability and server load. If a server lacks the requested data range, the router seamlessly redirects to a suitable alternative, ensuring a successful response for the user. +A native build compiles vendored C++ via a build script and uses `bindgen`, so you need a C/C++ toolchain plus `clang`/`libclang`: +| Requirement | Notes | +|---|---| +| Linux x86_64 | Only supported native target | +| Rust ≥ 1.85 (MSRV) | A transitive dependency uses the Rust 2024 edition | +| `git` | To clone and build from source | +| C/C++ toolchain | Debian/Ubuntu: `build-essential` | +| `clang` + `libclang-dev` | Required by `bindgen` | -### Fleet Router - Antelope SHiP Reverse Proxy & Load Balancer +On Debian/Ubuntu, install the prerequisites with: -Installing Rust +```bash +sudo apt-get install -y git clang libclang-dev build-essential +``` + +If you do not already have Rust: ```bash curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` -Installing Fleet Router from `crates.io` +## Installation + +> Native builds (crates.io or from source) require the [prerequisites above](#requirements-and-supported-platforms) to be installed first. macOS/Windows users should use the Docker image. + +### From crates.io ```bash cargo install fleet-router ``` -Installing Fleet Router from GitHub +### From source ```bash git clone https://github.com/eosrio/fleet-router.git @@ -33,40 +84,91 @@ cd fleet-router cargo install --path . ``` -Create the configuration file +### Docker + +A prebuilt image is published to the GitHub Container Registry on tagged releases: ```bash -fleet-router config init /path/to/config.json +docker pull ghcr.io/eosrio/fleet-router ``` -Configuration Reference +The image runs as a non-root user and defines a `HEALTHCHECK` on the proxy port (`17000`). See [Running in production](#running-in-production) for a full `docker run` example. + +## Quick start + +1. Write a sample config file: + + ```bash + fleet-router config init ./config.json + ``` -```json5 +2. Edit `config.json` to list your SHiP nodes (set each `endpoint` to `host:port`, no scheme) and adjust the bind address/port and intervals. + +3. Validate the config and test upstream connectivity: + + ```bash + fleet-router config test ./config.json + ``` + +4. Run the proxy: + + ```bash + fleet-router run --config ./config.json + ``` + +On startup, `run` validates the configuration before binding. Invalid configs fail fast with an actionable error; on success the router begins listening and logs its upstream monitors: + +``` +configuration is valid. +INFO starting upstream monitor name="SHiP Node 1" upstream=127.0.0.1:18080 +INFO listening for clients address=0.0.0.0 port=17000 +``` + +## Configuration + +Configuration is a single JSON file (`config.json` by default). The three `*_ms` fields are in **milliseconds**. Each upstream `endpoint` is `host:port` with **no scheme** — the router prepends `ws://` itself. + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `listen_address` | string | yes | — | Bind address for client connections (e.g. `0.0.0.0`). | +| `listen_port` | u16 | yes (non-zero) | `17000` (sample) | Port for client connections. | +| `upstream_reconnect_ms` | u64 | yes (> 0) | — | Milliseconds between upstream reconnection attempts. | +| `upstream_monitoring_ms` | u64 | yes (> 0) | — | Milliseconds between block-progress logging and staleness checks. | +| `upstream_status_ms` | u64 | yes (> 0) | — | Milliseconds between status requests sent to each upstream. | +| `servers` | array | yes (≥ 1 enabled) | — | List of upstream SHiP nodes (see below). | +| `max_connections` | usize | no | `10000` | Max concurrent client connections; excess are rejected (backpressure). | +| `handshake_timeout_ms` | u64 | no | `10000` | Client WebSocket handshake timeout; `0` disables it. | +| `idle_timeout_ms` | u64 | no | `0` (disabled) | Close a connection idle (no data in either direction) for this long. | +| `max_message_bytes` | usize | no | `268435456` (256 MiB) | Max WebSocket message size on both client and upstream links. | +| `shutdown_grace_ms` | u64 | no | `5000` | How long to wait for in-flight connections to drain on shutdown. | +| `metrics_address` | string | no | falls back to `listen_address` | Bind address for the health/metrics HTTP endpoint. | +| `metrics_port` | u16 | no | unset (endpoint disabled) | Port for the health/metrics HTTP endpoint. Setting it enables the endpoint. | + +Each entry in `servers` is an object: + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | yes | Human-readable name used in logs. | +| `endpoint` | string | yes | Upstream as `host:port` (no scheme; `ws://` is prepended). Endpoints must be unique. | +| `enabled` | bool | yes | Whether the router may use this upstream. At least one enabled server is required. | + +### Sample `config.json` + +```json { - // Address to Listen for client connections "listen_address": "0.0.0.0", - - // Port to listen for client connections "listen_port": 17000, - - // Interval to attempt reconnection to the backend servers "upstream_reconnect_ms": 3000, - - // Interval to log upstream status "upstream_monitoring_ms": 5000, - - // Interval to send status requests to upstream servers "upstream_status_ms": 5000, - - // Array of upstream SHiP nodes "servers": [ { - "name": "SHIP Node 1", // Server name for logging - "endpoint": "127.0.0.1:18080", // Websocket endpoint - "enabled": true // Allow fleet to use this upstream + "name": "SHiP Node 1", + "endpoint": "127.0.0.1:18080", + "enabled": true }, { - "name": "SHIP Node 2", + "name": "SHiP Node 2", "endpoint": "127.0.0.1:28080", "enabled": true } @@ -74,9 +176,121 @@ Configuration Reference } ``` -Usage +## Usage +```text +fleet-router config init Write a sample config file to . +fleet-router config test Parse and validate a config, then test upstream connectivity. +fleet-router run [--config ] Run the proxy. +fleet-router --version Print the version. +fleet-router --help Print help. ``` -fleet-router run --config /path/to/config.json + +The `--config` flag is global and defaults to `./config.json`, so `fleet-router run` with a `config.json` in the working directory is equivalent to passing `--config ./config.json`. + +## Observability + +### Logging + +Logging uses `tracing`. Control verbosity with the `RUST_LOG` environment variable (default `info`): + +```bash +# Everything at debug +RUST_LOG=debug fleet-router run --config config.json + +# Debug for fleet-router, info for everything else +RUST_LOG=fleet_router=debug,info fleet-router run --config config.json +``` + +### Health and metrics endpoint + +Set `metrics_port` (and optionally `metrics_address`) to enable an HTTP endpoint. It serves `GET` requests on: + +| Route | Response | +|---|---| +| `/health` | `200` while the process is running. | +| `/ready` | `200` if at least one upstream is online, otherwise `503`. | +| `/metrics` | Prometheus text exposition of router and upstream state. | + +Exposed metrics include: + +```text +fleet_router_up +fleet_router_upstream_up{endpoint} +fleet_router_upstream_stale{endpoint} +fleet_router_active_connections{endpoint} +fleet_router_upstream_chain_state_end_block{endpoint} +``` + +## Running in production + +> **Do not expose `fleet-router` directly to the public internet.** See [Security and limitations](#security-and-limitations). + +### systemd + +```ini +[Unit] +Description=Fleet SHiP Router +After=network-online.target +Wants=network-online.target + +[Service] +User=fleet +ExecStart=/usr/local/bin/fleet-router run --config /etc/fleet-router/config.json +Restart=on-failure +# SIGTERM triggers a graceful, bounded drain. Allow more than shutdown_grace_ms +# so systemd does not SIGKILL the process mid-drain. +TimeoutStopSec=10 +Environment=RUST_LOG=info + +[Install] +WantedBy=multi-user.target +``` + +`SIGTERM` (sent by `systemctl stop`) triggers graceful shutdown: the router stops accepting new connections, drains in-flight ones for up to `shutdown_grace_ms`, and sends WebSocket close frames to clients. Keep `TimeoutStopSec` comfortably larger than `shutdown_grace_ms`. + +### Docker + +```bash +docker run -p 17000:17000 \ + -v "$PWD/config.json:/etc/fleet-router.json:ro" \ + ghcr.io/eosrio/fleet-router run --config /etc/fleet-router.json ``` +The image runs as a non-root user and defines a `HEALTHCHECK` against port `17000`. + +### Tuning notes + +- **Intervals** (`upstream_reconnect_ms`, `upstream_monitoring_ms`, `upstream_status_ms`): lower values detect failures and staleness faster at the cost of more polling traffic to upstreams; raise them to reduce chatter. +- **Limits** (`max_connections`, `handshake_timeout_ms`, `idle_timeout_ms`, `max_message_bytes`): size `max_connections` for your expected concurrency (excess connections are rejected, not queued); set `idle_timeout_ms` to reap dead clients; lower `max_message_bytes` only if you are sure your blocks fit, since oversized frames are rejected on both links. + +## Security and limitations + +- Transport is **plaintext `ws://`** on both the client listener and the upstream connections. No TLS / `wss://` is compiled in. +- The client listener is **unauthenticated** — anyone who can reach the port can stream data. +- `rs_abieos` parses untrusted upstream bytes through C++ FFI. Treat your upstreams as part of the trust boundary. + +Because of the above: + +- Deploy on a trusted or internal network, and/or behind a TLS-terminating reverse proxy (nginx, Caddy, Envoy) that adds access control. +- **Do not expose `fleet-router` directly to the public internet.** + +## Contributing + +Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, the CI checks, and the pre-PR checklist (`cargo fmt --all`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace`, and a `CHANGELOG.md` entry). Tests run against an in-repo mock SHiP double and need no external services. + +## Security policy + +To report a vulnerability, see [SECURITY.md](SECURITY.md). Please do not open public issues for security reports. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release notes ([Keep a Changelog](https://keepachangelog.com/) format). + +## Code of conduct + +This project follows the [Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +Licensed under the [MIT License](LICENSE). © EOS Rio. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bc7e138 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,108 @@ +# Security Policy + +This document describes how to report security vulnerabilities in +**fleet-router** and the security properties you should assume when deploying +it. + +## Supported Versions + +Security fixes are provided for the latest `0.2.x` release line. Older +pre-release versions are not maintained; please upgrade to the latest `0.2.x` +release before reporting an issue. + +| Version | Supported | +| ------- | ------------------ | +| 0.2.x | :white_check_mark: | +| < 0.2 | :x: | + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** +Public issues disclose the problem before a fix is available and put other +users at risk. + +Report vulnerabilities privately using GitHub's +[Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. This is the primary and preferred channel: + +1. Go to the repository's **Security** tab: + +2. Click **Report a vulnerability**. +3. Fill in the advisory form with as much detail as you can. + +A helpful report typically includes: + +- A description of the vulnerability and its potential impact. +- The affected version(s) and platform. +- Steps to reproduce, or a proof of concept. +- The relevant configuration (with any secrets redacted). +- Any suggested remediation, if you have one. + +### What to expect + +We handle reports on a best-effort basis. We aim to acknowledge new reports +within a few business days and will keep you updated on our assessment and any +fix as it progresses. We may contact you through the advisory thread for +additional details. Please give us a reasonable opportunity to investigate and +release a fix before any public disclosure. + +## Security Model and Scope + +fleet-router is designed to run inside a trusted network boundary. Before +deploying, you should understand the following known properties. **These are +documented design limitations, not vulnerabilities, and reports about them will +be treated as such.** + +- **Unauthenticated listener.** The client-facing WebSocket listener performs no + authentication or authorization. Anyone able to reach the listen address can + open connections. +- **Plaintext transport, no TLS.** All transport is plaintext `ws://` on **both** + the client listener and the upstream SHiP connections. No TLS / `wss://` + support is compiled in, so traffic is neither encrypted nor integrity-protected + in transit. +- **Optional metrics endpoint is unauthenticated.** When enabled + (`metrics_port`), the HTTP `/health`, `/ready`, and `/metrics` endpoints are + served over plaintext HTTP with no authentication. + +Because of the above, fleet-router **must not be exposed directly to the public +internet**. Deploy it on a trusted/internal network and/or place it behind a +TLS-terminating reverse proxy (for example nginx, Caddy, or Envoy) that provides +encryption and access control. + +### Trust boundary + +Upstream SHiP nodes are part of the trust boundary. fleet-router parses bytes +received from upstreams through `rs_abieos`, a C++ library reached via FFI. +Only point fleet-router at upstream nodes you operate or otherwise trust. + +### In scope + +Reports that demonstrate behavior outside the documented design above are in +scope, including (non-exhaustively): + +- Memory safety issues, crashes, or panics triggered by client or upstream + input that is within the documented protocol. +- Resource-exhaustion vectors that bypass the configured safeguards + (`max_connections`, `handshake_timeout_ms`, `idle_timeout_ms`, + `max_message_bytes`). +- Logic flaws in failover, de-duplication, or shutdown that lead to data + corruption or unexpected disclosure between client connections. + +### Out of scope + +- The absence of TLS, client authentication, or authorization on the listener + and metrics endpoint (see the design limitations above). +- Issues that require a malicious or compromised upstream that you have + explicitly configured and trusted, beyond the memory-safety expectations + noted above. +- Vulnerabilities in third-party dependencies that are already tracked + upstream; please report those to the relevant project (we monitor advisories + via `cargo-deny` in CI). + +## See Also + +- [README.md](README.md) for deployment and configuration guidance. +- [CONTRIBUTING.md](CONTRIBUTING.md) for development and pull-request workflow. +- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community expectations. +- [CHANGELOG.md](CHANGELOG.md) for release history. +- [LICENSE](LICENSE) for licensing terms (MIT). diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9107a75 --- /dev/null +++ b/deny.toml @@ -0,0 +1,31 @@ +# cargo-deny configuration — see https://embarkstudios.github.io/cargo-deny/ +# Run locally with: cargo deny check + +[advisories] +version = 2 +# Add RUSTSEC ids here (with a tracking note) only when a fix is unavailable. +ignore = [] + +[licenses] +version = 2 +confidence-threshold = 0.8 +# Permissive licenses compatible with this crate's MIT license. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", +] + +[bans] +# Duplicate versions are common in large trees; surface but don't fail on them. +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index aa40aa9..14b871e 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -117,7 +117,7 @@ services: volumes: - ./fleet-router.json:/etc/fleet-router.json:ro ports: - - "9100:9000" + - "9100:17000" depends_on: nodeos-2: condition: service_started diff --git a/docker/fleet-router.json b/docker/fleet-router.json index 862a2cc..29bc61b 100644 --- a/docker/fleet-router.json +++ b/docker/fleet-router.json @@ -1,6 +1,6 @@ { "listen_address": "0.0.0.0", - "listen_port": 9000, + "listen_port": 17000, "upstream_reconnect_ms": 3000, "upstream_monitoring_ms": 5000, "upstream_status_ms": 5000, diff --git a/mock-ship/Cargo.toml b/mock-ship/Cargo.toml index bed8d85..5570a3d 100644 --- a/mock-ship/Cargo.toml +++ b/mock-ship/Cargo.toml @@ -3,6 +3,8 @@ name = "mock-ship" version = "0.1.0" edition = "2021" description = "Mock SHiP (State History Plugin) server for testing fleet-router" +license = "MIT" +publish = false [dependencies] tokio = { version = "1", features = ["net", "sync", "rt", "macros", "time"] } diff --git a/mock-ship/src/protocol.rs b/mock-ship/src/protocol.rs index 295a24f..2edb71f 100644 --- a/mock-ship/src/protocol.rs +++ b/mock-ship/src/protocol.rs @@ -104,7 +104,6 @@ pub fn encode_status_result_v0( buf } - /// Encode a get_blocks_result_v0 with optional data payloads. /// /// When `block_data`, `traces`, or `deltas` are `Some`, the optional is encoded as diff --git a/mock-ship/src/server.rs b/mock-ship/src/server.rs index d557f95..21cb53a 100644 --- a/mock-ship/src/server.rs +++ b/mock-ship/src/server.rs @@ -211,12 +211,18 @@ impl MockShipServer { None }; let traces_data = if fetch_traces && config.block_data_size > 0 { - Some(generate_fake_data(block_num.wrapping_add(1000), config.block_data_size)) + Some(generate_fake_data( + block_num.wrapping_add(1000), + config.block_data_size, + )) } else { None }; let deltas_data = if fetch_deltas && config.block_data_size > 0 { - Some(generate_fake_data(block_num.wrapping_add(2000), config.block_data_size)) + Some(generate_fake_data( + block_num.wrapping_add(2000), + config.block_data_size, + )) } else { None }; @@ -284,6 +290,16 @@ impl MockShipServer { fetch_traces: ft, fetch_deltas: fd, } => { + // When an explicit trace range is configured, model a + // real node that cannot serve blocks outside it: close + // the connection so the proxy fails over to a covering + // upstream. (No effect when trace_end_block is unset.) + if config.trace_end_block > 0 + && (start_block_num < config.trace_begin_block + || start_block_num >= config.trace_end_block) + { + return; + } current_block = Some(start_block_num); end_block = end_block_num; send_credits = max_messages_in_flight; diff --git a/src/connection_handler.rs b/src/connection_handler.rs index efee143..bb9c648 100644 --- a/src/connection_handler.rs +++ b/src/connection_handler.rs @@ -1,290 +1,370 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Duration; use futures::{SinkExt, StreamExt}; use rs_abieos::Abieos; -use tokio::sync::Notify; use tokio::net::TcpStream; use tokio::spawn; -use tokio::sync::Mutex; -use tokio::time::sleep; +use tokio::sync::{broadcast, Mutex, Notify}; +use tokio::time::{sleep, timeout}; use tokio_tungstenite::{ - accept_async, connect_async_with_config, tungstenite, MaybeTlsStream, WebSocketStream, + accept_async_with_config, connect_async_with_config, tungstenite, MaybeTlsStream, + WebSocketStream, }; use tungstenite::protocol::frame::coding::CloseCode; use tungstenite::protocol::{CloseFrame, WebSocketConfig}; use tungstenite::Message; use crate::functions::select_backend_server; -use crate::models::{ServerConfigDb, ServerStateDb}; +use crate::models::{ProxyLimits, ServerConfigDb, ServerStateDb}; +type UpstreamStream = WebSocketStream>; +/// Maximum client frames buffered while an upstream is mid-failover, after which +/// further frames during the (typically sub-second) swap window are dropped. +const MAX_PENDING_FRAMES: usize = 1024; + +/// Holds one unit of an upstream's active-connection counter. Decrements +/// synchronously (and underflow-safely) when dropped — no spawn, no lock, no +/// dependency on a live runtime handle. pub struct ConnectionGuard { - pub endpoint: String, - pub backend_servers: ServerStateDb, + counter: Arc, } impl Drop for ConnectionGuard { fn drop(&mut self) { - let endpoint = self.endpoint.clone(); - let db = self.backend_servers.clone(); - // Only spawn if the Tokio runtime is still alive (prevents panic during shutdown) - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { - let mut lock = db.lock().await; - if let Some(server) = lock.get_mut(&endpoint) { - if server.connections > 0 { - server.connections -= 1; - } - } - }); + // Saturating decrement: never underflows below zero. + let _ = self + .counter + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| c.checked_sub(1)); + } +} + +/// Aborts the wrapped task when dropped, so the client->server forwarding task +/// never outlives its session. +struct AbortOnDrop(tokio::task::JoinHandle<()>); +impl Drop for AbortOnDrop { + fn drop(&mut self) { + self.0.abort(); + } +} + +fn dec(counter: &AtomicUsize) { + let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |c| c.checked_sub(1)); +} + +async fn mark_offline(backend_servers: &ServerStateDb, endpoint: &str) { + if let Some(s) = backend_servers.lock().await.get_mut(endpoint) { + s.online = false; + } +} + +/// Read the upstream's first meaningful frame, which must be the Text ABI. +/// Leading Ping/Pong control frames are skipped. Returns `None` (so the caller +/// can try another upstream) on timeout, Close, Binary-first, or error. +async fn read_first_abi(ws: &mut UpstreamStream, handshake_timeout_ms: u64) -> Option { + loop { + let next = if handshake_timeout_ms > 0 { + match timeout(Duration::from_millis(handshake_timeout_ms), ws.next()).await { + Ok(n) => n, + Err(_) => return None, // handshake timed out + } + } else { + ws.next().await + }; + match next { + Some(Ok(Message::Text(text))) => return Some(text.to_string()), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + _ => return None, } } } -async fn get_socket( +/// Select, connect to, and complete the ABI handshake with an upstream, retrying +/// against other upstreams (up to `max_attempts`) on failure. Returns the +/// connection guard, the live socket, and the upstream's ABI JSON. +async fn establish_upstream( backend_servers: &ServerStateDb, server_config_db: &ServerConfigDb, max_attempts: u32, requested_block: Option, -) -> Option<(ConnectionGuard, WebSocketStream>)> { + limits: ProxyLimits, +) -> Option<(ConnectionGuard, UpstreamStream, String)> { for _ in 0..max_attempts { - // Select a backend server and optimistically increment counter - let server = { - let backend_servers_lock = &mut backend_servers.lock().await; - let selected_backend = match select_backend_server(backend_servers_lock, requested_block) { - Ok(endpoint) => endpoint, - Err(_) => { - continue; - } + // Select a backend and optimistically increment its counter under the lock. + let (cfg, counter) = { + let state = backend_servers.lock().await; + let selected = match select_backend_server(&state, requested_block) { + Ok(ep) => ep, + Err(_) => continue, }; - // Optimistic increment — prevents thundering herd - if let Some(s) = backend_servers_lock.get_mut(&selected_backend) { - s.connections += 1; - } - let server_config = server_config_db.lock().await; - let Some(cfg) = server_config.get(&selected_backend) else { - // Rollback optimistic increment - if let Some(s) = backend_servers_lock.get_mut(&selected_backend) { - s.connections -= 1; - } + let Some(server_state) = state.get(&selected) else { + continue; + }; + let counter = server_state.connections.clone(); + let Some(cfg) = server_config_db.lock().await.get(&selected).cloned() else { continue; }; - cfg.clone() + counter.fetch_add(1, Ordering::Relaxed); + (cfg, counter) }; let mut config = WebSocketConfig::default(); + config.max_message_size = Some(limits.max_message_bytes); + config.max_frame_size = Some(limits.max_message_bytes); - // Set the maximum message size to 1 GB - config.max_message_size = Some(1_073_741_824); - - // Connect to the selected server - match connect_async_with_config(server.ws_url(), Some(config), true).await { - Ok((ws_stream, _)) => { - println!("[client_handler] Connected to the server {}", server.name); - // Counter was already incremented optimistically - return Some(( - ConnectionGuard { - endpoint: server.endpoint.clone(), - backend_servers: backend_servers.clone(), - }, - ws_stream, - )); - } + let mut ws = match connect_async_with_config(cfg.ws_url(), Some(config), true).await { + Ok((ws, _)) => ws, Err(e) => { - eprintln!("[client_handler] Error connecting to the server: {}", e); - // Rollback optimistic increment and mark offline - { - let backend_server_lock = &mut backend_servers.lock().await; - if let Some(s) = backend_server_lock.get_mut(&server.endpoint) { - s.connections -= 1; - s.online = false; - } - } + tracing::warn!(upstream = %cfg.endpoint, error = %e, "error connecting to upstream"); + dec(&counter); + mark_offline(backend_servers, &cfg.endpoint).await; + continue; + } + }; + + match read_first_abi(&mut ws, limits.handshake_timeout_ms).await { + Some(abi) => { + tracing::debug!(upstream = %cfg.name, "connected to upstream"); + return Some((ConnectionGuard { counter }, ws, abi)); + } + None => { + tracing::warn!(upstream = %cfg.endpoint, "invalid or absent ABI handshake; trying another upstream"); + dec(&counter); + mark_offline(backend_servers, &cfg.endpoint).await; + let _ = ws.close(None).await; continue; } } } - eprintln!( - "[client_handler] Unable to connect to any server after {} attempts", - max_attempts + tracing::error!( + attempts = max_attempts, + "unable to establish an upstream connection" ); None } +/// Extract the `this_block.block_num` from a `get_blocks_result_v0/v1` frame, if +/// present. Layout: variant(1) + head(36) + lib(36) + this_block_flag(1) @73 + +/// block_num(4) @74. Returns `None` for any other frame (status, head keep-alive). +fn extract_block_num(msg: &Message) -> Option { + if let Message::Binary(bin) = msg { + if bin.len() >= 78 && (bin[0] == 1 || bin[0] == 2) && bin[73] == 1 { + if let Ok(bytes) = bin[74..78].try_into() { + return Some(u32::from_le_bytes(bytes)); + } + } + } + None +} pub async fn handle_client( client_stream: TcpStream, - client_address: std::net::SocketAddr, + _client_address: SocketAddr, backend_servers: ServerStateDb, server_config_db: ServerConfigDb, shared_abieos: Arc>, + limits: ProxyLimits, + mut shutdown: broadcast::Receiver<()>, ) { - // Start the WebSocket protocol on the accepted connection stream, gracefully close the connection if it fails - let mut client_websocket = match accept_async(client_stream).await { - Ok(ws_stream) => ws_stream, - Err(e) => { - eprintln!("Error during WebSocket handshake: {}", e); - return; + // 1. Complete the client WebSocket handshake (bounded in time and message size). + let mut client_config = WebSocketConfig::default(); + client_config.max_message_size = Some(limits.max_message_bytes); + client_config.max_frame_size = Some(limits.max_message_bytes); + + let accept_fut = accept_async_with_config(client_stream, Some(client_config)); + let mut client_websocket = if limits.handshake_timeout_ms > 0 { + match timeout( + Duration::from_millis(limits.handshake_timeout_ms), + accept_fut, + ) + .await + { + Ok(Ok(ws)) => ws, + Ok(Err(e)) => { + tracing::warn!(error = %e, "client websocket handshake failed"); + return; + } + Err(_) => { + tracing::warn!("client websocket handshake timed out"); + return; + } + } + } else { + match accept_fut.await { + Ok(ws) => ws, + Err(e) => { + tracing::warn!(error = %e, "client websocket handshake failed"); + return; + } } }; - println!("New incoming WebSocket connection: {}", client_address); + tracing::info!("client connected"); - let Some((mut _active_conn, server_socket)) = - get_socket(&backend_servers, &server_config_db, 3, None).await + // 2. Establish the first upstream and read its ABI. + let Some((mut active_conn, server_socket, server_abi)) = + establish_upstream(&backend_servers, &server_config_db, 3, None, limits).await else { - eprintln!("[client_handler] No upstream server available! Closing connection."); - // Gracefully close the client connection + tracing::error!("no upstream available; closing client"); let _ = client_websocket .close(Some(CloseFrame { code: CloseCode::Error, - reason: "No upstream server available!".into(), + reason: "No upstream server available".into(), })) .await; return; }; - // Split the client websocket - let (client_writer, mut client_reader) = client_websocket.split(); + // Validate the ABI against the shared abieos context. + { + let abieos = shared_abieos.lock().await; + if let Err(e) = abieos.set_abi_json("0", &server_abi) { + tracing::error!(error = %e, "error setting ABI from upstream"); + return; + } + } - // Add the client writer to a Mutex + let (client_writer, mut client_reader) = client_websocket.split(); let client_writer = Arc::new(Mutex::new(client_writer)); - let server_ship_abi: Arc>> = Arc::new(Mutex::new(None)); - // Store raw binary request bytes (V0 or V1) for failover replay - let last_request: Arc>>> = Arc::new(Mutex::new(None)); - // Get the server WebSocket reader and writer let (server_writer, server_reader_stream) = server_socket.split(); - // Add the server reader and writer to a Mutex let server_reader = Arc::new(Mutex::new(server_reader_stream)); let server_writer = Arc::new(Mutex::new(server_writer)); - // Store the server's ABI - let shared_abieos_arc = shared_abieos.clone(); + // Raw bytes of the latest get_blocks_request (v0/v1) for failover replay, and + // client frames that failed to forward during a failover window. + let last_request: Arc>>> = Arc::new(Mutex::new(None)); + let pending_client_frames: Arc>> = + Arc::new(Mutex::new(VecDeque::new())); + + // Forward the ABI to the client. { - let ship_abi_arc = server_ship_abi.clone(); - let server_reader_arc = server_reader.clone(); - let mut server_reader_guard = server_reader_arc.lock().await; - if let Some(Ok(Message::Text(text))) = server_reader_guard.next().await { - let abieos = shared_abieos_arc.lock().await; - if let Err(e) = abieos.set_abi_json("0", &text) { - eprintln!("[client_handler] Error setting ABI: {}", e); - return; - } - let mut server_ship_abi = ship_abi_arc.lock().await; - *server_ship_abi = Some(text.to_string()); - } else { - eprintln!("[client_handler] Error reading first message from server"); + let mut cw = client_writer.lock().await; + if let Err(e) = cw.send(Message::Text(server_abi.as_str().into())).await { + tracing::warn!(error = %e, "error sending ABI to client"); return; } } + tracing::debug!("sent ABI to client"); - // Client loop on a sub-task - let last_request_arc = last_request.clone(); - let server_writer_arc = server_writer.clone(); + // 3. Spawn the client -> server forwarding task. let client_disconnected = Arc::new(AtomicBool::new(false)); let client_disconnect_notify = Arc::new(Notify::new()); - let client_disconnected_tx = client_disconnected.clone(); - let client_disconnect_notify_tx = client_disconnect_notify.clone(); - // Forward messages from the client to the server, with request inspection - spawn(async move { - while let Some(Ok(msg)) = client_reader.next().await { - // Intercept V0 (variant 1) and V1 (variant 3) block requests — store raw bytes - if let Message::Binary(ref bin_msg) = msg { - if bin_msg.len() >= 5 && (bin_msg[0] == 1 || bin_msg[0] == 3) { - *last_request_arc.lock().await = Some(bin_msg.to_vec()); + let _c2s = { + let last_request = last_request.clone(); + let server_writer = server_writer.clone(); + let pending = pending_client_frames.clone(); + let disconnected = client_disconnected.clone(); + let notify = client_disconnect_notify.clone(); + let idle_ms = limits.idle_timeout_ms; + AbortOnDrop(spawn(async move { + loop { + let next = if idle_ms > 0 { + match timeout(Duration::from_millis(idle_ms), client_reader.next()).await { + Ok(n) => n, + Err(_) => { + tracing::info!("client idle timeout"); + break; + } + } + } else { + client_reader.next().await + }; + let Some(Ok(msg)) = next else { break }; + + // Record the latest blocks-request (v0=1, v1=3) for failover replay. + if let Message::Binary(ref bin) = msg { + if bin.len() >= 5 && (bin[0] == 1 || bin[0] == 3) { + *last_request.lock().await = Some(bin.to_vec()); + } } - } - let mut server_writer = server_writer_arc.lock().await; - if let Err(e) = server_writer.send(msg).await { - eprintln!("[client_handler] Error sending to server: {}", e); - continue; // Don't break — failover may swap the socket - } - } - - println!("Client stream ended"); - client_disconnected_tx.store(true, Ordering::Release); - client_disconnect_notify_tx.notify_one(); - }); - - // Send the server's ABI to the client - let ship_abi_arc = server_ship_abi.clone(); - { - let server_ship_abi = ship_abi_arc.lock().await; - let client_writer_clone = client_writer.clone(); - if let Some(abi) = &*server_ship_abi { - println!("[client_handler] Sending ABI to client..."); - let mut client_writer = client_writer_clone.lock().await; - if let Err(e) = client_writer - .send(Message::Text(abi.as_str().into())) - .await - { - eprintln!("[client_handler] Error sending ABI to client: {}", e); - return; + let mut sw = server_writer.lock().await; + if let Err(e) = sw.send(msg.clone()).await { + // Don't silently drop: buffer for replay after failover swaps the socket. + tracing::debug!(error = %e, "forward to upstream failed; buffering for failover"); + drop(sw); + let mut p = pending.lock().await; + if p.len() < MAX_PENDING_FRAMES { + p.push_back(msg); + } else { + tracing::warn!("pending client-frame buffer full; dropping frame"); + } + } } - } - } - - // Server loop — inline block tracking (zero-copy, no background task) - let client_writer_arc = client_writer.clone(); - let server_reader_arc = server_reader.clone(); - let ship_abi_arc = server_ship_abi.clone(); - let shared_abieos_arc = shared_abieos.clone(); + disconnected.store(true, Ordering::Release); + notify.notify_one(); + tracing::debug!("client stream ended"); + })) + }; - let mut client_writer = client_writer_arc.lock().await; - let mut server_reader = server_reader_arc.lock().await; + // 4. Server -> client loop with inline de-duplication and failover. + let mut cw_guard = client_writer.lock().await; + let mut sr_guard = server_reader.lock().await; + let idle_ms = limits.idle_timeout_ms; let mut restarting = false; let mut last_seen_block: Option = None; - let client_disconnected_rx = client_disconnected.clone(); - let client_disconnect_notify_rx = client_disconnect_notify.clone(); - loop { - // Keep forwarding messages from the server to the client + 'session: loop { let mut client_down = false; loop { - // Check if client already disconnected before blocking on server - if client_disconnected_rx.load(Ordering::Acquire) { - println!("[client_handler] Client already disconnected"); + if client_disconnected.load(Ordering::Acquire) { client_down = true; break; } - let msg = tokio::select! { - msg = server_reader.next() => msg, - _ = client_disconnect_notify_rx.notified() => { - println!("[client_handler] Client disconnected while waiting for server data"); - client_down = true; - break; + + let next_msg: Option = if idle_ms > 0 { + tokio::select! { + _ = shutdown.recv() => { + let _ = cw_guard.send(Message::Close(Some(CloseFrame { + code: CloseCode::Away, + reason: "server shutting down".into(), + }))).await; + return; + } + _ = client_disconnect_notify.notified() => { client_down = true; break; } + r = timeout(Duration::from_millis(idle_ms), sr_guard.next()) => match r { + Ok(Some(Ok(m))) => Some(m), + Ok(_) => None, + Err(_) => { tracing::debug!("upstream idle timeout; failing over"); None } + } } - }; - let Some(Ok(msg)) = msg else { break; }; - - // Extract block_num from binary layout (zero-copy): - // get_blocks_result_v0 (variant 1) or v1 (variant 2) - // variant(1) + head(4+32) + lib(4+32) + this_block_flag(1) = offset 74 - let mut current_block: Option = None; - if let Message::Binary(ref bin) = msg { - if bin.len() >= 78 && (bin[0] == 1 || bin[0] == 2) && bin[73] == 1 { - if let Ok(bytes) = bin[74..78].try_into() { - current_block = Some(u32::from_le_bytes(bytes)); + } else { + tokio::select! { + _ = shutdown.recv() => { + let _ = cw_guard.send(Message::Close(Some(CloseFrame { + code: CloseCode::Away, + reason: "server shutting down".into(), + }))).await; + return; + } + _ = client_disconnect_notify.notified() => { client_down = true; break; } + r = sr_guard.next() => match r { + Some(Ok(m)) => Some(m), + _ => None, } } - } + }; + + let Some(msg) = next_msg else { break }; - // Inline dedup during failover (no clones, no channels) + let current_block = extract_block_num(&msg); + + // During failover, drop only duplicate block frames; forward everything + // else (status results, head keep-alives) so the client never stalls. if restarting { if let Some(b_num) = current_block { - let expected = last_seen_block.map_or(0, |b| b + 1); + let expected = last_seen_block.map_or(0, |b| b.saturating_add(1)); if b_num < expected { - continue; // drop duplicate block + continue; // duplicate replayed block } restarting = false; - } else { - continue; // skip non-block frames until we sync up } } @@ -292,98 +372,101 @@ pub async fn handle_client( last_seen_block = Some(b_num); } - if let Err(e) = client_writer.send(msg).await { - eprintln!("[client_handler] Error sending message to client: {}", e); + if let Err(e) = cw_guard.send(msg).await { + tracing::warn!(error = %e, "error sending message to client"); client_down = true; break; } - } // end of server reader loop + } - // if the client closed the connection, break the final loop if client_down { - break; + break 'session; } - // Update last_request with the actual next block for failover replay + // Upstream ended — patch the replay request to resume at the next block. { let mut lr = last_request.lock().await; - if let Some(ref mut req_bytes) = *lr { + if let Some(req) = lr.as_mut() { if let Some(lb) = last_seen_block { - let next_block = lb + 1; - if req_bytes.len() >= 5 { - req_bytes[1..5].copy_from_slice(&next_block.to_le_bytes()); + let next_block = lb.saturating_add(1); + if req.len() >= 5 { + req[1..5].copy_from_slice(&next_block.to_le_bytes()); } } - // If last_seen_block is None, keep original start_block (no blocks were sent) } } + tracing::warn!("upstream stream ended; attempting failover"); - println!("[client_handler] Server stream ended"); - - // now we must select another server to reconnect - // Extract requested start_block from last_request for range-aware routing let failover_block = { let lr = last_request.lock().await; lr.as_ref().and_then(|req| { if req.len() >= 5 { - Some(u32::from_le_bytes(req[1..5].try_into().unwrap())) + req[1..5].try_into().ok().map(u32::from_le_bytes) } else { None } }) }; - let Some((new_conn, new_stream)) = get_socket(&backend_servers, &server_config_db, 3, failover_block).await + let Some((new_conn, new_stream, new_abi)) = establish_upstream( + &backend_servers, + &server_config_db, + 3, + failover_block, + limits, + ) + .await else { - eprintln!("[client_handler] No upstream server available! Closing connection."); - break; + tracing::error!("no upstream available after failover; closing client"); + let _ = cw_guard + .send(Message::Close(Some(CloseFrame { + code: CloseCode::Error, + reason: "no upstream available".into(), + }))) + .await; + break 'session; }; - _active_conn = new_conn; // drops the old guard, decrementing its counter - - // Re-split and update mutexes - let mut server_writer_lock = server_writer.lock().await; - let (writer, reader) = new_stream.split(); - *server_reader = reader; - *server_writer_lock = writer; - drop(server_writer_lock); - - // Get the first message from the server - let first_message = server_reader.next().await; - if let Some(Ok(Message::Text(text))) = first_message { - // Use abieos to set the ABI and confirm that the server response is valid - let abieos = shared_abieos_arc.lock().await; - match abieos.set_abi_json("0", &text) { - Ok(_) => { - let mut server_ship_abi = ship_abi_arc.lock().await; - *server_ship_abi = Some(text.to_string()); - println!("[client_handler] ABI set successfully"); - } - Err(e) => { - eprintln!("[client_handler] Error setting ABI: {}", e); - break; - } + active_conn = new_conn; // drops the old guard (synchronous decrement) + + { + let abieos = shared_abieos.lock().await; + if let Err(e) = abieos.set_abi_json("0", &new_abi) { + tracing::error!(error = %e, "error setting ABI after failover"); + break 'session; } - } else { - eprintln!("[client_handler] Error reading first message from server"); - break; } - // Sleep briefly before resuming - sleep(Duration::from_millis(100)).await; + // Swap in the new socket. + { + let (writer, reader) = new_stream.split(); + *sr_guard = reader; + *server_writer.lock().await = writer; + } - // Replay the last request (raw bytes, already patched with next block) - let lr = last_request.lock().await; - if let Some(req_bytes) = &*lr { - let msg = Message::Binary(req_bytes.clone().into()); - let mut server_writer_lock = server_writer.lock().await; - if let Err(e) = server_writer_lock.send(msg).await { - eprintln!("[client_handler] Error replaying request: {}", e); - break; + // Brief pause, then replay the request and flush any buffered client frames. + sleep(Duration::from_millis(100)).await; + { + let mut sw = server_writer.lock().await; + let lr = last_request.lock().await; + if let Some(req) = lr.as_ref() { + if let Err(e) = sw.send(Message::Binary(req.clone().into())).await { + tracing::warn!(error = %e, "error replaying request after failover"); + break 'session; + } + } + drop(lr); + let mut pending = pending_client_frames.lock().await; + while let Some(frame) = pending.pop_front() { + if let Err(e) = sw.send(frame).await { + tracing::warn!(error = %e, "error flushing buffered client frame after failover"); + break; + } } } - drop(lr); - println!("Reconnected to the server"); + tracing::info!("reconnected to upstream after failover"); restarting = true; } + + let _ = active_conn; // keep the guard alive for the whole session } diff --git a/src/functions.rs b/src/functions.rs index d97e246..038a058 100644 --- a/src/functions.rs +++ b/src/functions.rs @@ -1,25 +1,38 @@ use std::collections::HashMap; +use std::sync::atomic::Ordering; use crate::errors; use crate::models::ServerState; -/// Select a backend server from the list of available servers. +/// Select a backend server from the list of available (enabled + online) servers. /// -/// When `requested_block` is `Some`, prefer servers whose trace range covers that block -/// (least connections among those). Falls back to any online server if none covers the range. +/// Candidates are ranked, best first: +/// +/// 1. covers the requested block AND is fresh (advancing) +/// 2. covers the requested block but is stale +/// 3. does not cover the block but is fresh +/// 4. does not cover the block and is stale +/// +/// Within the same rank, the server with the fewest active connections wins +/// (least-connections load balancing). Stale servers are deprioritized but never +/// excluded, so selection never starves when every upstream is stale. +/// +/// The range check uses an exclusive upper bound (`block < trace_end_block`) because SHiP's +/// `get_status_result` reports `trace_end_block` as one-past-the-last available block. pub fn select_backend_server( - servers: &mut HashMap, + servers: &HashMap, requested_block: Option, ) -> Result { - let mut best_in_range: Option<(String, usize)> = None; - let mut best_fallback: Option<(String, usize)> = None; + // Lower (rank, connections) is better. + let mut best: Option<(u8, usize, String)> = None; for (server, state) in servers.iter() { if !state.enabled || !state.online { continue; } - // Check if this server's trace range covers the requested block + let conns = state.connections.load(Ordering::Relaxed); + let covers_range = match requested_block { Some(block) => { state.trace_end_block > 0 @@ -29,37 +42,40 @@ pub fn select_backend_server( None => false, }; - if covers_range - && (best_in_range.is_none() - || state.connections < best_in_range.as_ref().unwrap().1) - { - best_in_range = Some((server.clone(), state.connections)); - } + let rank = match (covers_range, state.stale) { + (true, false) => 0u8, + (true, true) => 1, + (false, false) => 2, + (false, true) => 3, + }; - // Always track the overall least-connections fallback - if best_fallback.is_none() || state.connections < best_fallback.as_ref().unwrap().1 { - best_fallback = Some((server.clone(), state.connections)); + let better = match &best { + None => true, + Some((best_rank, best_conns, _)) => (rank, conns) < (*best_rank, *best_conns), + }; + if better { + best = Some((rank, conns, server.clone())); } } - // Prefer in-range, fall back to any online server - if let Some((server, _)) = best_in_range { - Ok(server) - } else if let Some((server, _)) = best_fallback { - if let Some(block) = requested_block { - eprintln!( - "[select_backend] Warning: no upstream covers block {}, falling back to least connections", - block - ); + match best { + Some((rank, _, server)) => { + if let Some(block) = requested_block { + if rank >= 2 { + tracing::warn!( + block, + "no upstream covers the requested block; falling back to least-connections" + ); + } + } + Ok(server) } - Ok(server) - } else { - Err(errors::NO_SERVERS_AVAILABLE) + None => Err(errors::NO_SERVERS_AVAILABLE), } } pub fn buffer_to_hex(buffer: Vec) -> String { - let mut hex = String::new(); + let mut hex = String::with_capacity(buffer.len() * 2); for byte in buffer { hex.push_str(&format!("{:02x}", byte)); } diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..5e9694c --- /dev/null +++ b/src/health.rs @@ -0,0 +1,184 @@ +//! Minimal, dependency-free HTTP endpoint exposing liveness, readiness, and +//! Prometheus metrics. Opt-in via the `metrics_port` config field. +//! +//! Routes (GET only): +//! - `/health`, `/healthz` -> 200 while the process is running +//! - `/ready`, `/readyz` -> 200 if at least one upstream is online, else 503 +//! - `/metrics` -> Prometheus text exposition of upstream state + +use std::sync::atomic::Ordering; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::broadcast; +use tokio::time::timeout; + +use crate::models::ServerStateDb; + +/// Run the health/metrics HTTP server until a shutdown signal is received. +pub async fn run_metrics_server( + addr: String, + state_db: ServerStateDb, + mut shutdown: broadcast::Receiver<()>, +) { + let listener = match TcpListener::bind(&addr).await { + Ok(l) => l, + Err(e) => { + tracing::error!(%addr, error = %e, "failed to bind health/metrics endpoint"); + return; + } + }; + tracing::info!(%addr, "health/metrics endpoint listening"); + + loop { + tokio::select! { + accepted = listener.accept() => { + match accepted { + Ok((stream, _peer)) => { + let db = state_db.clone(); + tokio::spawn(async move { + if let Err(e) = handle_request(stream, db).await { + tracing::debug!(error = %e, "health/metrics request error"); + } + }); + } + Err(e) => tracing::warn!(error = %e, "health/metrics accept error"), + } + } + _ = shutdown.recv() => { + tracing::info!("health/metrics endpoint shutting down"); + break; + } + } + } +} + +async fn handle_request(mut stream: TcpStream, state_db: ServerStateDb) -> std::io::Result<()> { + // Read just enough to parse the request line. Bounded in both time and size. + let mut buf = [0u8; 1024]; + let mut len = 0; + while len < buf.len() { + let n = match timeout(Duration::from_secs(5), stream.read(&mut buf[len..])).await { + Ok(Ok(0)) => break, // EOF + Ok(Ok(n)) => n, // read some bytes + Ok(Err(e)) => return Err(e), + Err(_) => break, // read timeout + }; + len += n; + if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") || buf[..len].contains(&b'\n') { + break; // we have the request line (and possibly headers) + } + } + + let head = String::from_utf8_lossy(&buf[..len]); + let request_line = head.lines().next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let path = parts.next().unwrap_or(""); + + let (status, content_type, body) = if method != "GET" { + ( + "405 Method Not Allowed", + "text/plain", + "method not allowed\n".to_string(), + ) + } else { + match path { + "/health" | "/healthz" => ("200 OK", "text/plain", "ok\n".to_string()), + "/ready" | "/readyz" => { + let online = count_online(&state_db).await; + if online > 0 { + ( + "200 OK", + "text/plain", + format!("ready: {} upstream(s) online\n", online), + ) + } else { + ( + "503 Service Unavailable", + "text/plain", + "not ready: no upstreams online\n".to_string(), + ) + } + } + "/metrics" => ( + "200 OK", + "text/plain; version=0.0.4", + render_metrics(&state_db).await, + ), + _ => ("404 Not Found", "text/plain", "not found\n".to_string()), + } + }; + + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await?; + stream.flush().await?; + Ok(()) +} + +async fn count_online(state_db: &ServerStateDb) -> usize { + state_db.lock().await.values().filter(|s| s.online).count() +} + +async fn render_metrics(state_db: &ServerStateDb) -> String { + let db = state_db.lock().await; + let mut out = String::new(); + + out.push_str("# HELP fleet_router_up Whether the router process is up.\n"); + out.push_str("# TYPE fleet_router_up gauge\n"); + out.push_str("fleet_router_up 1\n"); + + out.push_str( + "# HELP fleet_router_upstream_up Whether an upstream is online (1) or offline (0).\n", + ); + out.push_str("# TYPE fleet_router_upstream_up gauge\n"); + for (endpoint, state) in db.iter() { + out.push_str(&format!( + "fleet_router_upstream_up{{endpoint=\"{}\"}} {}\n", + escape(endpoint), + u8::from(state.online) + )); + } + + out.push_str("# HELP fleet_router_upstream_stale Whether an upstream has stopped advancing (1) or not (0).\n"); + out.push_str("# TYPE fleet_router_upstream_stale gauge\n"); + for (endpoint, state) in db.iter() { + out.push_str(&format!( + "fleet_router_upstream_stale{{endpoint=\"{}\"}} {}\n", + escape(endpoint), + u8::from(state.stale) + )); + } + + out.push_str( + "# HELP fleet_router_active_connections Active client connections routed to an upstream.\n", + ); + out.push_str("# TYPE fleet_router_active_connections gauge\n"); + for (endpoint, state) in db.iter() { + out.push_str(&format!( + "fleet_router_active_connections{{endpoint=\"{}\"}} {}\n", + escape(endpoint), + state.connections.load(Ordering::Relaxed) + )); + } + + out.push_str("# HELP fleet_router_upstream_chain_state_end_block Last chain-state block reported by an upstream.\n"); + out.push_str("# TYPE fleet_router_upstream_chain_state_end_block gauge\n"); + for (endpoint, state) in db.iter() { + out.push_str(&format!( + "fleet_router_upstream_chain_state_end_block{{endpoint=\"{}\"}} {}\n", + escape(endpoint), + state.chain_state_end_block + )); + } + + out +} + +fn escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/src/main.rs b/src/main.rs index 7d89aaf..695dcc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,19 @@ +//! # fleet-router +//! +//! A reverse proxy and load balancer for the Antelope **SHiP** (State History +//! Plugin) WebSocket protocol. Clients connect to the router; it selects an +//! upstream SHiP node (range-aware, least-connections) and proxies the +//! WebSocket bidirectionally, transparently failing over to another upstream +//! and de-duplicating replayed blocks on reconnect. +//! +//! Run with `fleet-router run --config config.json`. See the project README for +//! the full configuration reference and operational guidance. + use std::any::Any; use std::fs::{read_to_string, write}; use std::path::PathBuf; use std::sync::Arc; +use std::time::{Duration, Instant}; use anyhow::{bail, Result}; use clap::{arg, value_parser, Command}; @@ -11,9 +23,11 @@ use rs_abieos::Abieos; use serde_json::from_str; use tokio::net::TcpListener; use tokio::spawn; -use tokio::sync::Mutex; +use tokio::sync::{broadcast, Mutex, Semaphore}; +use tokio::time::sleep; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; +use tracing::Instrument; use crate::config_sample::CONFIG_SAMPLE; use crate::connection_handler::handle_client; @@ -27,6 +41,7 @@ mod config_sample; mod connection_handler; mod errors; mod functions; +mod health; mod models; mod tasks; mod zcd; @@ -41,14 +56,15 @@ async fn main() -> Result<()> { } conf } - Ok(None) => { - return Ok(()); - } - Err(e) => { - bail!(e) - } + Ok(None) => return Ok(()), + Err(e) => bail!(e), }; + init_tracing(); + + // Validate the configuration up front with clear, actionable errors. + config.validate()?; + let main_abieos = Abieos::new(); let ctx = main_abieos.as_ptr(); if ctx.is_null() { @@ -56,117 +72,163 @@ async fn main() -> Result<()> { } let shared_abieos = Arc::new(Mutex::new(Abieos::from_context(ctx))); - if config.servers.iter().filter(|s| s.enabled).count() == 0 { - bail!("No enabled servers found in config.json. Aborting."); - } - - // global settings let static_config = StaticConfig { - listen_port: config.listen_port, upstream_status_ms: config.upstream_status_ms, upstream_monitoring_ms: config.upstream_monitoring_ms, - listen_address: config.listen_address, upstream_reconnect_ms: config.upstream_reconnect_ms, }; + let limits = config.proxy_limits(); - if static_config.listen_address.is_empty() { - bail!("Invalid address"); - } - - // Create a shared state for the server configuration HashMap let server_config_db: ServerConfigDb = build_config_db(config.servers.clone()); - - // Create a shared state for the server state HashMap let server_state_db: ServerStateDb = build_state_db(config.servers.clone()); - // Backend Monitoring Loop + // Graceful-shutdown broadcast: every long-lived task subscribes. + let (shutdown_tx, _) = broadcast::channel::<()>(16); + + // Per-upstream monitoring loops. for server in config.servers.iter().filter(|s| s.enabled).cloned() { - println!("{}", server.name); - // Get references - let server_state_db_clone = server_state_db.clone(); - let abieos_arc_clone = shared_abieos.clone(); - println!("Monitoring started for server: {}", server.endpoint); + tracing::info!(name = %server.name, upstream = %server.endpoint, "starting upstream monitor"); spawn(monitoring_connection( server, static_config.clone(), - server_state_db_clone, - abieos_arc_clone, + server_state_db.clone(), + shared_abieos.clone(), + shutdown_tx.subscribe(), )); } - // spawn a new async task to print the server state every 5 seconds - let server_state_db_clone = server_state_db.clone(); + // Periodic block-progress / staleness monitor. spawn(state_monitoring_loop( - server_state_db_clone, + server_state_db.clone(), static_config.upstream_monitoring_ms, + shutdown_tx.subscribe(), )); - let listener = match TcpListener::bind(format!( - "{}:{}", - static_config.listen_address, static_config.listen_port - )) - .await - { - Ok(listener) => listener, - Err(e) => { - bail!("Error binding to address: {}", e); - } - }; + // Optional health/metrics HTTP endpoint. + if let Some(port) = config.metrics_port { + let addr = format!( + "{}:{}", + config + .metrics_address + .clone() + .unwrap_or_else(|| config.listen_address.clone()), + port + ); + spawn(health::run_metrics_server( + addr, + server_state_db.clone(), + shutdown_tx.subscribe(), + )); + } - println!( - "Listening on: {}:{}", - static_config.listen_address, static_config.listen_port - ); + let bind_addr = format!("{}:{}", config.listen_address, config.listen_port); + let listener = TcpListener::bind(&bind_addr) + .await + .map_err(|e| anyhow::anyhow!("error binding to {}: {}", bind_addr, e))?; + tracing::info!(address = %config.listen_address, port = config.listen_port, "listening for clients"); - // Graceful shutdown channel - let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1); + // Connection backpressure: at most `max_connections` concurrent clients. + let conn_limit = Arc::new(Semaphore::new(config.max_connections)); + let max_connections = config.max_connections; loop { tokio::select! { - // Accept new TCP connections on the main thread result = listener.accept() => { let (client_stream, client_addr) = match result { - Ok((stream, address)) => (stream, address), + Ok(pair) => pair, Err(e) => { - eprintln!("Error accepting incoming connection: {}", e); - // continue to the next iteration of the loop + tracing::warn!(error = %e, "error accepting incoming connection"); continue; } }; - println!( - "New incoming TCP connection: {}:{}", - client_addr.ip(), - client_addr.port() - ); + // Reject immediately when at capacity (backpressure, not queueing). + let permit = match conn_limit.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + tracing::warn!(%client_addr, max = max_connections, "connection limit reached; rejecting"); + drop(client_stream); + continue; + } + }; + + tracing::debug!(%client_addr, "accepted tcp connection"); let s_state_db = server_state_db.clone(); let s_config_db = server_config_db.clone(); let abieos = shared_abieos.clone(); - let mut shutdown_rx = shutdown_tx.subscribe(); - - // Spawn a new task to handle the new TCP connection - spawn(async move { - tokio::select! { - _ = handle_client(client_stream, client_addr, s_state_db, s_config_db, abieos) => {} - _ = shutdown_rx.recv() => { - println!("[{}] Connection closed due to server shutdown", client_addr); - } + let shutdown_rx = shutdown_tx.subscribe(); + + spawn( + async move { + let _permit = permit; // released when this task ends + handle_client( + client_stream, + client_addr, + s_state_db, + s_config_db, + abieos, + limits, + shutdown_rx, + ) + .await; } - }); + .instrument(tracing::info_span!("client", addr = %client_addr)), + ); } - _ = tokio::signal::ctrl_c() => { - println!("\n[main] Received Ctrl+C / SIGINT. Shutting down gracefully..."); + _ = shutdown_signal() => { + tracing::info!("shutdown signal received; draining connections"); let _ = shutdown_tx.send(()); - - // Allow a brief moment for inflight connections to drop naturally - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + drain_connections(&conn_limit, max_connections, config.shutdown_grace_ms).await; + tracing::info!("shutdown complete"); break Ok(()); } } } } +/// Initialize `tracing` with a `RUST_LOG`-driven filter (default `info`). +fn init_tracing() { + use tracing_subscriber::{fmt, EnvFilter}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + fmt().with_env_filter(filter).with_target(false).init(); +} + +/// Resolve when a shutdown signal (SIGINT/Ctrl+C, or SIGTERM on Unix) arrives. +#[cfg(unix)] +async fn shutdown_signal() { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = term.recv() => {} + } +} + +#[cfg(not(unix))] +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +/// Wait (up to `grace_ms`) for active client connections to drain. +async fn drain_connections(conn_limit: &Arc, max: usize, grace_ms: u64) { + let start = Instant::now(); + while conn_limit.available_permits() < max { + if start.elapsed() >= Duration::from_millis(grace_ms) { + let remaining = max - conn_limit.available_permits(); + tracing::warn!(remaining, "shutdown grace period elapsed; forcing exit"); + break; + } + sleep(Duration::from_millis(50)).await; + } +} + async fn run_tests(servers: Vec) { let mut valid_count = 0; for server in servers { @@ -221,7 +283,7 @@ async fn run_tests(servers: Vec) { fn process_args() -> Result> { let cmd = Command::new("fleet-router") .about("Protocol-aware reverse proxy for Antelope SHiP") - .version("0.2.0") + .version(env!("CARGO_PKG_VERSION")) .arg( arg!(--"config" ) .global(true) @@ -258,14 +320,13 @@ fn process_args() -> Result> { if let Some(("config", config)) = matches.subcommand() { match config.subcommand() { Some(("init", init)) => { - let path = { - if let Some(config_out_path) = init.get_one::("CONFIG_OUT") { - config_out_path.to_owned() - } else { - PathBuf::from("./config.json") - } - }; - write(&path, CONFIG_SAMPLE).unwrap(); + let path = init + .get_one::("CONFIG_OUT") + .cloned() + .unwrap_or_else(|| PathBuf::from("./config.json")); + if let Err(e) = write(&path, CONFIG_SAMPLE) { + bail!("failed to write config file {}: {}", path.display(), e); + } let display_path = path.canonicalize().unwrap_or(path); cprintln!( "Creating new config file: {:?}", @@ -275,40 +336,27 @@ fn process_args() -> Result> { return Ok(None); } Some(("test", test)) => match test.get_one::("CONFIG") { - None => { - bail!("missing config file"); - } - Some(test_path) => { - return Ok(test_config(test_path)); - } + None => bail!("missing config file"), + Some(test_path) => return Ok(test_config(test_path)), }, _ => {} }; - } else if let Some(("run", _run_matches)) = matches.subcommand() { - // Explicit run subcommand, which is exactly the same as no subcommand (default) - // No-op here since the config arg is registered globally } let config_path = match matches.get_one::("config") { Some(path) => path, - None => { - bail!("missing config path"); - } + None => bail!("missing config path"), }; - println!("Using config file at: {:?}", config_path); + tracing::debug!(?config_path, "loading configuration"); let config_contents = match read_to_string(config_path) { Ok(data) => data, - Err(e) => { - bail!("failed to read configuration file: {}", e); - } + Err(e) => bail!("failed to read configuration file: {}", e), }; let config: ServerConfig = match from_str(&config_contents) { Ok(config) => config, - Err(e) => { - bail!("failed to parse configuration file: {}", e); - } + Err(e) => bail!("failed to parse configuration file: {}", e), }; Ok(Some((false, config))) @@ -316,18 +364,20 @@ fn process_args() -> Result> { fn test_config(path: &PathBuf) -> Option<(bool, ServerConfig)> { match read_to_string(path) { - Ok(data) => { - match from_str::(&data) { - Ok(config) => { - // println!("{:#?}", config); - Some((true, config)) - } - Err(e) => { - cprintln!("failed to parse configuration file: {}", e); - None + Ok(data) => match from_str::(&data) { + Ok(config) => { + if let Err(e) = config.validate() { + cprintln!("configuration is invalid: {}", e); + return None; } + cprintln!("configuration is valid."); + Some((true, config)) } - } + Err(e) => { + cprintln!("failed to parse configuration file: {}", e); + None + } + }, Err(e) => { cprintln!("failed to read configuration file: {}", e); None diff --git a/src/models.rs b/src/models.rs index 5446aaa..207f5b7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,17 +1,42 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::atomic::AtomicUsize; use std::sync::Arc; +use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; pub type ServerStateDb = Arc>>; pub type ServerConfigDb = Arc>>; -// config.json file format +// --------------------------------------------------------------------------- +// serde defaults for optional, backwards-compatible config fields +// --------------------------------------------------------------------------- + +fn default_max_connections() -> usize { + 10_000 +} +fn default_handshake_timeout_ms() -> u64 { + 10_000 +} +fn default_idle_timeout_ms() -> u64 { + 0 // 0 = disabled +} +fn default_max_message_bytes() -> usize { + 256 * 1024 * 1024 // 256 MiB +} +fn default_shutdown_grace_ms() -> u64 { + 5_000 +} + +/// A single upstream SHiP node, as declared in `config.json`. #[derive(Debug, Deserialize, Serialize, Hash, Eq, PartialEq, Clone)] pub struct Server { + /// Human-readable name used in logs. pub name: String, + /// Upstream WebSocket endpoint as `host:port` (no scheme; `ws://` is prepended). pub endpoint: String, + /// Whether the router is allowed to use this upstream. pub enabled: bool, } @@ -21,22 +46,116 @@ impl Server { } } +/// The on-disk `config.json` schema. #[derive(Debug, Serialize, Deserialize)] pub struct ServerConfig { - pub listen_port: u16, + /// Address the proxy listens on for client connections. pub listen_address: String, + /// Port the proxy listens on for client connections. + pub listen_port: u16, + /// Interval (ms) between reconnection attempts to a downed upstream. pub upstream_reconnect_ms: u64, + /// Interval (ms) at which upstream status is logged. pub upstream_monitoring_ms: u64, + /// Interval (ms) at which status requests are sent to upstreams. pub upstream_status_ms: u64, + /// The upstream SHiP nodes to load-balance across. pub servers: Vec, + + /// Maximum number of concurrent client connections accepted (backpressure). + #[serde(default = "default_max_connections")] + pub max_connections: usize, + /// Timeout (ms) for completing the client WebSocket handshake. 0 = disabled. + #[serde(default = "default_handshake_timeout_ms")] + pub handshake_timeout_ms: u64, + /// Idle timeout (ms): close a connection that exchanges no data for this long. 0 = disabled. + #[serde(default = "default_idle_timeout_ms")] + pub idle_timeout_ms: u64, + /// Maximum WebSocket message size (bytes) accepted on both client and upstream links. + #[serde(default = "default_max_message_bytes")] + pub max_message_bytes: usize, + /// How long (ms) to wait for in-flight connections to drain on shutdown. + #[serde(default = "default_shutdown_grace_ms")] + pub shutdown_grace_ms: u64, + /// Optional address for the HTTP health/metrics endpoint. Defaults to `listen_address`. + #[serde(default)] + pub metrics_address: Option, + /// Optional port for the HTTP health/metrics endpoint. When unset, the endpoint is disabled. + #[serde(default)] + pub metrics_port: Option, } -#[derive(Debug, Hash, Eq, PartialEq, Clone)] +impl ServerConfig { + /// Validate the configuration, returning a clear error for any invalid field. + pub fn validate(&self) -> Result<()> { + if self.listen_address.trim().is_empty() { + bail!("listen_address must not be empty"); + } + if self.listen_port == 0 { + bail!("listen_port must be non-zero"); + } + if self.upstream_reconnect_ms == 0 { + bail!("upstream_reconnect_ms must be greater than 0"); + } + if self.upstream_monitoring_ms == 0 { + bail!("upstream_monitoring_ms must be greater than 0"); + } + if self.upstream_status_ms == 0 { + bail!("upstream_status_ms must be greater than 0"); + } + if self.max_connections == 0 { + bail!("max_connections must be greater than 0"); + } + if self.max_message_bytes == 0 { + bail!("max_message_bytes must be greater than 0"); + } + if self.servers.is_empty() { + bail!("config must define at least one server"); + } + let enabled: Vec<&Server> = self.servers.iter().filter(|s| s.enabled).collect(); + if enabled.is_empty() { + bail!("config must have at least one enabled server"); + } + let mut seen = HashSet::new(); + for s in &enabled { + if s.endpoint.trim().is_empty() { + bail!("server '{}' has an empty endpoint", s.name); + } + if !seen.insert(s.endpoint.as_str()) { + bail!( + "duplicate upstream endpoint '{}' — endpoints must be unique", + s.endpoint + ); + } + } + if let Some(0) = self.metrics_port { + bail!("metrics_port must be non-zero when set"); + } + Ok(()) + } + + /// The per-connection proxy limits derived from this config. + pub fn proxy_limits(&self) -> ProxyLimits { + ProxyLimits { + handshake_timeout_ms: self.handshake_timeout_ms, + idle_timeout_ms: self.idle_timeout_ms, + max_message_bytes: self.max_message_bytes, + } + } +} + +/// Live state tracked per upstream by the monitoring loop and the proxy. +#[derive(Debug)] pub struct ServerState { - // ... Add fields to track server usage (e.g., connections) - pub connections: usize, + /// Active client connections currently routed to this upstream. + /// `Arc` so [`crate::connection_handler::ConnectionGuard`] can + /// decrement synchronously on drop without locking the whole map. + pub connections: Arc, pub enabled: bool, pub online: bool, + /// Set by the monitoring loop when an upstream stops advancing its chain + /// state. Stale upstreams are deprioritized (but not excluded) when routing. + pub stale: bool, pub trace_begin_block: u32, pub trace_end_block: u32, pub chain_state_begin_block: u32, @@ -46,9 +165,10 @@ pub struct ServerState { impl ServerState { pub fn new() -> ServerState { ServerState { - connections: 0, + connections: Arc::new(AtomicUsize::new(0)), enabled: true, online: false, + stale: false, trace_begin_block: 0, trace_end_block: 0, chain_state_begin_block: 0, @@ -57,20 +177,31 @@ impl ServerState { } } +impl Default for ServerState { + fn default() -> Self { + Self::new() + } +} +/// Interval settings shared with the background monitoring tasks. #[derive(Debug, Clone)] pub struct StaticConfig { - pub listen_port: u16, - pub listen_address: String, pub upstream_reconnect_ms: u64, pub upstream_monitoring_ms: u64, pub upstream_status_ms: u64, } -pub fn build_state_db(servers: Vec) -> Arc>> { +/// Per-connection limits applied by the proxy data path. +#[derive(Debug, Clone, Copy)] +pub struct ProxyLimits { + pub handshake_timeout_ms: u64, + pub idle_timeout_ms: u64, + pub max_message_bytes: usize, +} + +pub fn build_state_db(servers: Vec) -> ServerStateDb { Arc::new(Mutex::new( servers - .clone() .iter() .filter(|s| s.enabled) .map(|s| (s.endpoint.clone(), ServerState::new())) @@ -78,12 +209,12 @@ pub fn build_state_db(servers: Vec) -> Arc) -> Arc>> { +pub fn build_config_db(servers: Vec) -> ServerConfigDb { Arc::new(Mutex::new( servers .into_iter() .filter(|s| s.enabled) - .map(|s| (s.endpoint.clone(), s.clone())) + .map(|s| (s.endpoint.clone(), s)) .collect::>(), )) } diff --git a/src/tasks.rs b/src/tasks.rs index c1ae311..22c18c2 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -2,144 +2,158 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use chrono::Utc; use futures::stream::SplitSink; use futures::{SinkExt, StreamExt}; use rs_abieos::Abieos; use tokio::net::TcpStream; -use tokio::sync::Mutex; +use tokio::sync::{broadcast, Mutex}; use tokio::time::sleep; use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; -use crate::models::{Server, ServerState, StaticConfig}; +use crate::models::{Server, ServerStateDb, StaticConfig}; use crate::zcd; use crate::zcd::ZCDValues; type WsSender = Arc>, Message>>>; +/// Number of consecutive monitoring intervals without chain-state advancement +/// before an upstream is flagged stale (and deprioritized for routing). +const STALE_THRESHOLD: u32 = 12; + +/// Periodically log per-upstream block progress and flag stale upstreams. pub async fn state_monitoring_loop( - server_state_db_clone: Arc>>, + server_state_db: ServerStateDb, interval_ms: u64, -) -> () { + mut shutdown: broadcast::Receiver<()>, +) { let mut last_block: HashMap = HashMap::new(); let mut stale_counter: HashMap = HashMap::new(); loop { - sleep(Duration::from_millis(interval_ms)).await; - let mut server_state_db = server_state_db_clone.lock().await; - let mut updated = 0; - for (server, state) in server_state_db.iter_mut() { - if !last_block.contains_key(server) { - last_block.insert(server.clone(), 0); - stale_counter.insert(server.clone(), 0); - } + tokio::select! { + _ = shutdown.recv() => break, + _ = sleep(Duration::from_millis(interval_ms)) => {} + } - let Some(last_block_number) = last_block.get_mut(server) else { - continue; - }; - let Some(stale_counter) = stale_counter.get_mut(server) else { - continue; - }; + let mut db = server_state_db.lock().await; + let mut updated = 0; + for (server, state) in db.iter_mut() { + let last = last_block.entry(server.clone()).or_insert(0); + let counter = stale_counter.entry(server.clone()).or_insert(0); - if state.chain_state_end_block > *last_block_number { - *last_block_number = state.chain_state_end_block; - *stale_counter = 0; + if state.chain_state_end_block > *last { + *last = state.chain_state_end_block; + *counter = 0; updated += 1; - // println!("Server {}: {} - Last State Block {}", index, server, *last_block_number); + if state.stale { + state.stale = false; + tracing::info!(upstream = %server, "upstream resumed advancing"); + } } else { - *stale_counter += 1; - if *stale_counter > 10 { - eprintln!("Server: {} - No New Blocks", server); + *counter += 1; + if *counter == STALE_THRESHOLD && !state.stale { + state.stale = true; + tracing::warn!( + upstream = %server, + intervals = STALE_THRESHOLD, + "upstream not advancing; flagged stale and deprioritized" + ); } } } if updated > 0 { - println!("[{}] - Last Blocks: {:?}", Utc::now(), last_block); + tracing::debug!(?last_block, "upstream block progress"); } } + tracing::debug!("state monitoring loop stopped"); } -pub async fn send_status_loop(sender: WsSender, interval_ms: u64) -> () { +async fn send_status_loop(sender: WsSender, interval_ms: u64) { loop { sleep(Duration::from_millis(interval_ms)).await; let message = Message::Binary(vec![0u8].into()); let mut s = sender.lock().await; - match s.send(message).await { - Ok(_) => (), - Err(e) => { - println!("Error sending status request: {}", e); - break; - } + if let Err(e) = s.send(message).await { + tracing::debug!(error = %e, "status-ping send failed; stopping ping loop"); + break; } } } +/// Maintain a monitoring connection to a single upstream: connect, poll status, +/// keep its `ServerState` up to date, and reconnect with capped backoff. pub async fn monitoring_connection( server: Server, static_config: StaticConfig, - server_state_db_clone: Arc>>, - abieos_arc_clone: Arc>, -) -> () { - // println!("{:?}", static_config); - + server_state_db: ServerStateDb, + abieos: Arc>, + mut shutdown: broadcast::Receiver<()>, +) { let mut ship_abi: Option = None; let mut server_closed = false; + let mut backoff_attempts: u32 = 0; + const MAX_BACKOFF_MS: u64 = 30_000; loop { - // Get websocket connection with the upstream backend - let websocket = match connect_async(server.ws_url()).await { - Ok((websocket, _)) => { - // println!("{:?}", resp); - websocket - } - Err(_) => { - eprintln!("Error connecting to server"); - println!( - "Retrying connection in {} ms...", - static_config.upstream_reconnect_ms - ); - sleep(Duration::from_millis(static_config.upstream_reconnect_ms)).await; - continue; + let websocket = tokio::select! { + _ = shutdown.recv() => break, + res = connect_async(server.ws_url()) => match res { + Ok((websocket, _)) => websocket, + Err(e) => { + let backoff = (static_config.upstream_reconnect_ms + .saturating_mul(1u64 << backoff_attempts.min(5))) + .min(MAX_BACKOFF_MS); + backoff_attempts = backoff_attempts.saturating_add(1); + tracing::warn!(upstream = %server.endpoint, error = %e, retry_in_ms = backoff, "upstream connect failed"); + tokio::select! { + _ = shutdown.recv() => break, + _ = sleep(Duration::from_millis(backoff)) => continue, + } + } } }; - println!("Connected to server: {}", server.endpoint); + backoff_attempts = 0; + tracing::info!(upstream = %server.endpoint, "monitoring connected to upstream"); let (sender, mut receiver) = websocket.split(); let sender = Arc::new(Mutex::new(sender)); - let s1 = sender.clone(); - tokio::spawn(send_status_loop(s1, static_config.upstream_status_ms)); + let status_loop = tokio::spawn(send_status_loop( + sender.clone(), + static_config.upstream_status_ms, + )); - let s2 = sender.clone(); loop { - let msg = match receiver.next().await { - Some(Ok(msg)) => msg, - None => { - // if the server closed the connection, break the inner loop but keep trying to reconnect - println!("{} disconnected!", server.endpoint); - ship_abi = None; - - // mark the server as offline - let mut db = server_state_db_clone.lock().await; - if let Some(state) = db.get_mut(&server.endpoint) { - state.online = false; - } + let msg = tokio::select! { + _ = shutdown.recv() => { + server_closed = true; break; } - _ => { - println!("Error reading next message!"); - break; + next = receiver.next() => match next { + Some(Ok(msg)) => msg, + None => { + tracing::info!(upstream = %server.endpoint, "upstream disconnected"); + ship_abi = None; + if let Some(state) = server_state_db.lock().await.get_mut(&server.endpoint) { + state.online = false; + } + break; + } + Some(Err(e)) => { + tracing::warn!(upstream = %server.endpoint, error = %e, "error reading from upstream"); + break; + } } }; - // println!("{:?}",msg); + handle_monitoring_msg( msg, &mut server_closed, &mut ship_abi, - &abieos_arc_clone, - &s2, - &server_state_db_clone, + &abieos, + &sender, + &server_state_db, &server, ) .await; @@ -147,105 +161,92 @@ pub async fn monitoring_connection( if server_closed { break; } - } // end of receiver loop + } + + // Stop this connection's status-ping task before reconnecting. + status_loop.abort(); - // if the server closed the connection, break the loop if server_closed { break; } - println!( - "Retrying connection in {} ms...", - static_config.upstream_reconnect_ms - ); - sleep(Duration::from_millis(static_config.upstream_reconnect_ms)).await; + tracing::info!(upstream = %server.endpoint, retry_in_ms = static_config.upstream_reconnect_ms, "reconnecting to upstream"); + tokio::select! { + _ = shutdown.recv() => break, + _ = sleep(Duration::from_millis(static_config.upstream_reconnect_ms)) => {} + } } + tracing::debug!(upstream = %server.endpoint, "monitoring loop stopped"); } async fn handle_monitoring_msg( message: Message, server_closed: &mut bool, ship_abi: &mut Option, - abieos_arc_clone: &Arc>, + abieos: &Arc>, sender: &WsSender, - server_state_db_clone: &Arc>>, + server_state_db: &ServerStateDb, server: &Server, -) -> () { +) { match message { Message::Text(msg) => { if ship_abi.is_none() { *ship_abi = Some(msg.to_string()); - let abieos = abieos_arc_clone.lock().await; - println!("Abieos Context: {:?}", abieos.as_ptr()); + let abieos = abieos.lock().await; match abieos.set_abi_json_native(0u64, ship_abi.as_deref().unwrap()) { - Ok(x) => { - if x { - let message = Message::Binary(vec![0u8].into()); - let mut s = sender.lock().await; - s.send(message).await.unwrap_or_else(|e| { - eprintln!("Error sending message: {}", e); - }); + Ok(true) => { + let mut s = sender.lock().await; + if let Err(e) = s.send(Message::Binary(vec![0u8].into())).await { + tracing::warn!(upstream = %server.endpoint, error = %e, "failed to send initial status request"); } } + Ok(false) => { + tracing::warn!(upstream = %server.endpoint, "abieos rejected upstream ABI") + } Err(_) => { - println!("Error setting ABI"); + tracing::warn!(upstream = %server.endpoint, "error setting ABI for upstream") } - }; + } } else { - println!("Received unexpected text message from server"); + tracing::debug!(upstream = %server.endpoint, "unexpected text message from upstream"); } } Message::Binary(bin_msg) => { let result_message = zcd::deserialize_result(&bin_msg); - let Some(variant) = result_message.get("variant") else { + let Some(ZCDValues::U8(0)) = result_message.get("variant") else { return; }; - if let ZCDValues::U8(v) = variant { - if v == 0 { - let Some(data) = result_message.get("data") else { - return; - }; - if let ZCDValues::Bytes(bytes) = data { - let result = zcd::deserialize_status_result(&bytes); - - let Some(ZCDValues::U32(head_block_num)) = result.get("head_block_num") - else { - return; - }; - if head_block_num > 0 { - let mut server_state_db = server_state_db_clone.lock().await; - if let Some(state) = server_state_db.get_mut(&server.endpoint) { - state.enabled = true; - state.online = true; - if let Some(ZCDValues::U32(tb)) = result.get("trace_begin_block") { - state.trace_begin_block = tb; - } - if let Some(ZCDValues::U32(te)) = result.get("trace_end_block") { - state.trace_end_block = te; - } - if let Some(ZCDValues::U32(cb)) = - result.get("chain_state_begin_block") - { - state.chain_state_begin_block = cb; - } - if let Some(ZCDValues::U32(ce)) = - result.get("chain_state_end_block") - { - state.chain_state_end_block = ce; - } - } - } - } else { - eprintln!("Received unexpected type for status data"); + let Some(ZCDValues::Bytes(bytes)) = result_message.get("data") else { + return; + }; + let result = zcd::deserialize_status_result(&bytes); + + let Some(ZCDValues::U32(head_block_num)) = result.get("head_block_num") else { + return; + }; + if head_block_num > 0 { + let mut db = server_state_db.lock().await; + if let Some(state) = db.get_mut(&server.endpoint) { + state.enabled = true; + state.online = true; + if let Some(ZCDValues::U32(tb)) = result.get("trace_begin_block") { + state.trace_begin_block = tb; + } + if let Some(ZCDValues::U32(te)) = result.get("trace_end_block") { + state.trace_end_block = te; + } + if let Some(ZCDValues::U32(cb)) = result.get("chain_state_begin_block") { + state.chain_state_begin_block = cb; + } + if let Some(ZCDValues::U32(ce)) = result.get("chain_state_end_block") { + state.chain_state_end_block = ce; } } - } else { - eprintln!("Received unexpected type for variant"); } } Message::Close(_) => { - eprintln!("Received close message from server"); + tracing::info!(upstream = %server.endpoint, "received close from upstream"); *server_closed = true; } _ => {} diff --git a/src/zcd.rs b/src/zcd.rs index 48b6ece..7854f42 100644 --- a/src/zcd.rs +++ b/src/zcd.rs @@ -225,10 +225,14 @@ fn zcd_builder<'a>(buffer: &'a [u8], fields: &'a [(&ZCDType, &str, usize)]) -> Z match f_type { Array => { // unbounded fields in the middle of the array - if offset >= buffer.len() { break; } + if offset >= buffer.len() { + break; + } let elements = buffer[offset]; let full_size = 1 + size * elements as usize; - if offset + full_size > buffer.len() { break; } + if offset + full_size > buffer.len() { + break; + } let field_buffer = &buffer[offset..offset + full_size]; hash_map.insert( field.to_string(), @@ -259,7 +263,9 @@ fn zcd_builder<'a>(buffer: &'a [u8], fields: &'a [(&ZCDType, &str, usize)]) -> Z } } else { // bounded fields - if offset + size > buffer.len() { break; } + if offset + size > buffer.len() { + break; + } let field_buffer = &buffer[offset..offset + size]; hash_map.insert( field.to_string(), diff --git a/tests/e2e_proxy.rs b/tests/e2e_proxy.rs index 54ecf58..b952661 100644 --- a/tests/e2e_proxy.rs +++ b/tests/e2e_proxy.rs @@ -652,7 +652,10 @@ async fn e2e_proxy_failover() { .unwrap(); if let Message::Binary(data) = read_one(&mut reader).await { let head = u32::from_le_bytes(data[1..5].try_into().unwrap()); - assert_eq!(head, 6000, "After failover, should route to surviving upstream"); + assert_eq!( + head, 6000, + "After failover, should route to surviving upstream" + ); successes += 1; } drop(writer); @@ -661,7 +664,10 @@ async fn e2e_proxy_failover() { sleep(Duration::from_millis(100)).await; } - println!(" After failover: {} successful connections to upstream 6000", successes); + println!( + " After failover: {} successful connections to upstream 6000", + successes + ); assert!( successes >= 3, "Expected at least 3 successful connections after failover, got {}", @@ -751,7 +757,9 @@ async fn e2e_proxy_sustained_streaming() { println!( " Sustained: {} total blocks across {} clients in {:.2}s", - total_received, num_clients, elapsed.as_secs_f64() + total_received, + num_clients, + elapsed.as_secs_f64() ); assert_eq!( @@ -953,7 +961,10 @@ async fn e2e_proxy_range_aware_routing() { } } - println!(" Range routing: heads seen = {:?}, blocks = {}", heads_seen, blocks_received); + println!( + " Range routing: heads seen = {:?}, blocks = {}", + heads_seen, blocks_received + ); // All blocks should come from upstream A (head=5000), never from B (head=10000) assert!( @@ -1069,7 +1080,9 @@ async fn e2e_proxy_failover_to_range_valid() { } // Send acks to keep flow going if blocks_received.is_multiple_of(10) { - let _ = writer.send(Message::Binary(build_ack_request(10).into())).await; + let _ = writer + .send(Message::Binary(build_ack_request(10).into())) + .await; } if blocks_received >= 20 { break; diff --git a/tests/operational.rs b/tests/operational.rs new file mode 100644 index 0000000..8d5a2ff --- /dev/null +++ b/tests/operational.rs @@ -0,0 +1,192 @@ +//! Operational tests for fleet-router: configuration validation and the +//! optional health/metrics HTTP endpoint. +//! +//! Run: cargo test --test operational +use std::io::Write; +use std::process::{Child, Command}; +use std::time::Duration; + +use mock_ship::{MockShipConfig, MockShipServer}; +use tokio::time::sleep; + +fn find_binary() -> String { + for path in ["target/debug/fleet-router", "target/release/fleet-router"] { + if std::path::Path::new(path).exists() { + return path.to_string(); + } + } + panic!("fleet-router binary not found. Run `cargo build` first."); +} + +fn free_port() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() +} + +fn write_config(json: &serde_json::Value) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(json.to_string().as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +struct ChildGuard(Child); +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +// --------------------------------------------------------------------------- +// Configuration validation +// --------------------------------------------------------------------------- + +/// `run` must reject an invalid config (here: a duplicate upstream endpoint) +/// before binding, exiting with a non-zero status. +#[test] +fn run_rejects_invalid_config() { + let cfg = serde_json::json!({ + "listen_address": "127.0.0.1", + "listen_port": free_port(), + "upstream_reconnect_ms": 1000, + "upstream_monitoring_ms": 2000, + "upstream_status_ms": 1000, + "servers": [ + { "name": "a", "endpoint": "127.0.0.1:19999", "enabled": true }, + { "name": "b", "endpoint": "127.0.0.1:19999", "enabled": true } + ] + }); + let file = write_config(&cfg); + let output = Command::new(find_binary()) + .arg("run") + .arg("--config") + .arg(file.path()) + .output() + .expect("failed to spawn fleet-router"); + assert!( + !output.status.success(), + "expected non-zero exit for a config with duplicate endpoints" + ); +} + +/// A zero interval is rejected. +#[test] +fn run_rejects_zero_interval() { + let cfg = serde_json::json!({ + "listen_address": "127.0.0.1", + "listen_port": free_port(), + "upstream_reconnect_ms": 1000, + "upstream_monitoring_ms": 2000, + "upstream_status_ms": 0, + "servers": [ + { "name": "a", "endpoint": "127.0.0.1:19999", "enabled": true } + ] + }); + let file = write_config(&cfg); + let output = Command::new(find_binary()) + .arg("run") + .arg("--config") + .arg(file.path()) + .output() + .expect("failed to spawn fleet-router"); + assert!( + !output.status.success(), + "expected non-zero exit for upstream_status_ms = 0" + ); +} + +// --------------------------------------------------------------------------- +// Health / metrics endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn metrics_endpoint_serves_health_ready_and_metrics() { + // Start a mock upstream so the router has something to report on. + let mock = MockShipServer::new(MockShipConfig { + head_block: 1000, + lib_block: 990, + ..Default::default() + }) + .await; + let mock_endpoint = mock.endpoint(); + let _mock_handle = mock.start(); + + let listen_port = free_port(); + let metrics_port = free_port(); + let cfg = serde_json::json!({ + "listen_address": "127.0.0.1", + "listen_port": listen_port, + "upstream_reconnect_ms": 1000, + "upstream_monitoring_ms": 1000, + "upstream_status_ms": 500, + "metrics_address": "127.0.0.1", + "metrics_port": metrics_port, + "servers": [ + { "name": "mock", "endpoint": mock_endpoint, "enabled": true } + ] + }); + let file = write_config(&cfg); + let child = Command::new(find_binary()) + .arg("run") + .arg("--config") + .arg(file.path()) + .spawn() + .expect("failed to spawn fleet-router"); + let _guard = ChildGuard(child); + + let base = format!("http://127.0.0.1:{}", metrics_port); + let client = reqwest::Client::new(); + + // Wait for the metrics server to come up. + let mut up = false; + for _ in 0..50 { + if client + .get(format!("{}/health", base)) + .send() + .await + .map(|r| r.status().is_success()) + .unwrap_or(false) + { + up = true; + break; + } + sleep(Duration::from_millis(100)).await; + } + assert!(up, "metrics /health did not become available"); + + // /metrics exposes the expected gauges and the upstream label. + let body = client + .get(format!("{}/metrics", base)) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + assert!( + body.contains("fleet_router_up"), + "missing fleet_router_up: {body}" + ); + assert!( + body.contains("fleet_router_active_connections"), + "missing active_connections gauge" + ); + assert!( + body.contains(&mock_endpoint), + "metrics should label the configured upstream endpoint" + ); + + // /ready should become 200 once the upstream is observed online. + let mut ready = false; + for _ in 0..50 { + if let Ok(resp) = client.get(format!("{}/ready", base)).send().await { + if resp.status().is_success() { + ready = true; + break; + } + } + sleep(Duration::from_millis(100)).await; + } + assert!(ready, "/ready never reported an online upstream"); +} diff --git a/tests/stress_test.rs b/tests/stress_test.rs index 7837798..c90e33b 100644 --- a/tests/stress_test.rs +++ b/tests/stress_test.rs @@ -289,15 +289,8 @@ async fn stress_concurrent_heavy_clients_mock() { writer .send(Message::Binary( - build_blocks_request_with_data( - start, - end, - max_in_flight, - true, - true, - true, - ) - .into(), + build_blocks_request_with_data(start, end, max_in_flight, true, true, true) + .into(), )) .await .unwrap(); @@ -380,10 +373,7 @@ where test_fn().await; // Stop load - let _ = client - .post(format!("{}/stop", base)) - .send() - .await; + let _ = client.post(format!("{}/stop", base)).send().await; // Print final status if let Ok(res) = client.get(format!("{}/status", base)).send().await { @@ -428,8 +418,15 @@ async fn stress_block_data_via_router_docker() { let start = if head > count { head - count } else { 1 }; writer .send(Message::Binary( - build_blocks_request_with_data(start, start + count, max_in_flight, true, true, true) - .into(), + build_blocks_request_with_data( + start, + start + count, + max_in_flight, + true, + true, + true, + ) + .into(), )) .await .unwrap(); @@ -536,4 +533,3 @@ async fn stress_concurrent_clients_via_router_docker() { }) .await; } - From 1ae6cdb8cd3e442f0dc62dac83b6a8b53faee671 Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Sat, 30 May 2026 12:26:44 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20=E2=80=94=20bound?= =?UTF-8?q?=20handshake/metrics=20timeouts,=20close=20failover-buffer=20ra?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised by automated review (gemini-code-assist): - connection_handler: keep holding `server_writer` while buffering a failed client frame into `pending`, so the failover loop cannot acquire the writer, swap the socket, and flush `pending` before the frame is enqueued (which would strand/reorder it). - connection_handler: apply a single overall timeout to the upstream ABI handshake instead of per-frame, so a peer dripping Ping/Pong frames cannot keep the handshake alive past the deadline. - health: apply a single overall timeout to the metrics request-head read instead of resetting it per read (anti-Slowloris on the metrics port). --- src/connection_handler.rs | 34 ++++++++++++++++++++-------------- src/health.rs | 39 ++++++++++++++++++++++++++------------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/connection_handler.rs b/src/connection_handler.rs index bb9c648..d5417e0 100644 --- a/src/connection_handler.rs +++ b/src/connection_handler.rs @@ -66,20 +66,24 @@ async fn mark_offline(backend_servers: &ServerStateDb, endpoint: &str) { /// Leading Ping/Pong control frames are skipped. Returns `None` (so the caller /// can try another upstream) on timeout, Close, Binary-first, or error. async fn read_first_abi(ws: &mut UpstreamStream, handshake_timeout_ms: u64) -> Option { - loop { - let next = if handshake_timeout_ms > 0 { - match timeout(Duration::from_millis(handshake_timeout_ms), ws.next()).await { - Ok(n) => n, - Err(_) => return None, // handshake timed out + // Single overall deadline for the whole handshake: a peer that drips + // Ping/Pong frames cannot keep it alive past the timeout. + let read = async { + loop { + match ws.next().await { + Some(Ok(Message::Text(text))) => return Some(text.to_string()), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + _ => return None, } - } else { - ws.next().await - }; - match next { - Some(Ok(Message::Text(text))) => return Some(text.to_string()), - Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, - _ => return None, } + }; + if handshake_timeout_ms > 0 { + // On timeout (Err), unwrap_or_default yields None — treat as a failed handshake. + timeout(Duration::from_millis(handshake_timeout_ms), read) + .await + .unwrap_or_default() + } else { + read.await } } @@ -286,9 +290,11 @@ pub async fn handle_client( let mut sw = server_writer.lock().await; if let Err(e) = sw.send(msg.clone()).await { - // Don't silently drop: buffer for replay after failover swaps the socket. + // Don't silently drop: buffer for replay after failover swaps the + // socket. Keep holding `server_writer` while pushing to `pending` + // so the failover loop cannot acquire the writer, swap, and flush + // `pending` before this frame is buffered (which would strand it). tracing::debug!(error = %e, "forward to upstream failed; buffering for failover"); - drop(sw); let mut p = pending.lock().await; if p.len() < MAX_PENDING_FRAMES { p.push_back(msg); diff --git a/src/health.rs b/src/health.rs index 5e9694c..8717996 100644 --- a/src/health.rs +++ b/src/health.rs @@ -55,21 +55,34 @@ pub async fn run_metrics_server( } async fn handle_request(mut stream: TcpStream, state_db: ServerStateDb) -> std::io::Result<()> { - // Read just enough to parse the request line. Bounded in both time and size. + // Read just enough to parse the request line, bounded by a single overall + // timeout (so a slow client can't hold the connection open by dripping + // bytes) and a fixed buffer size. let mut buf = [0u8; 1024]; - let mut len = 0; - while len < buf.len() { - let n = match timeout(Duration::from_secs(5), stream.read(&mut buf[len..])).await { - Ok(Ok(0)) => break, // EOF - Ok(Ok(n)) => n, // read some bytes - Ok(Err(e)) => return Err(e), - Err(_) => break, // read timeout - }; - len += n; - if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") || buf[..len].contains(&b'\n') { - break; // we have the request line (and possibly headers) + let len = match timeout(Duration::from_secs(5), async { + let mut len = 0; + while len < buf.len() { + match stream.read(&mut buf[len..]).await { + Ok(0) => break, // EOF + Ok(n) => { + len += n; + if buf[..len].windows(4).any(|w| w == b"\r\n\r\n") + || buf[..len].contains(&b'\n') + { + break; // we have the request line (and possibly headers) + } + } + Err(e) => return Err(e), + } } - } + Ok(len) + }) + .await + { + Ok(Ok(len)) => len, + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), // overall read timeout: drop the connection + }; let head = String::from_utf8_lossy(&buf[..len]); let request_line = head.lines().next().unwrap_or("");