fleet-router
A reverse proxy and load balancer for the Antelope SHiP (State History Plugin) WebSocket protocol.
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 and tokio-tungstenite, and uses rs_abieos (pure-Rust backend) to check that an upstream SHiP ABI parses. Each handshake, failover, and monitor builds a short-lived AbiHandle and drops it; connections do not share an abieos context.
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. A
get_blocks_request_v1withfetch_finality_datais sent only to an upstream whose finality-data range covers that block (learned fromget_status_result_v1). If no upstream reports a covering range, routing stays on the trace-range rules. - Automatic failover with de-duplication — on upstream loss, reconnects to another suitable node, resumes
get_blocksat 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.
- Multiple networks in one process —
listenersmode serves several chains, each on its own port with its own upstream pool,chain_id, connection cap, and/ready/<name>check. chain_idverification — upstreams reporting a differentchain_idfrom the pinned value or the rest of their pool are kept offline. Older nodes that omitchain_id(FIO and other EOSIO 2.0-based builds) stay usable unlessrequire_chain_idis set.- Graceful shutdown — handles
SIGINT/SIGTERMwith a bounded drain and WebSocket close frames to connected clients. - Structured logging —
tracing-based, controlled withRUST_LOG. - Optional health/metrics endpoint — liveness, readiness, and Prometheus metrics over HTTP, enabled on demand. Per-upstream series carry
serverandendpointlabels and include each SHiP range's start and end blocks. - Upstream
wss://— optional TLS to SHiP nodes. A barehost:portstaysws://. Certificates are verified with rustls against the OS trust store. The client listener remains plaintext. - 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)
Requirements and supported platforms
fleet-router builds on Linux, macOS, and Windows (x86_64 and arm64). ABI handling uses the pure-Rust rs_abieos backend, so libclang is not required. Upstream TLS links aws-lc-rs through rustls. Building that provider needs a C compiler and CMake on PATH.
| Requirement | Notes |
|---|---|
| Rust ≥ 1.99 (MSRV) | Toolchain used to build, test, and publish fleet-router |
| C compiler and CMake | Used to build aws-lc-rs for wss://. Not required at runtime. |
If you do not already have Rust, install it via rustup:
|
Installation
From crates.io
From source
Docker
A prebuilt image is published to the GitHub Container Registry on tagged releases:
The image runs as a non-root user and defines a HEALTHCHECK that runs fleet-router check against the proxy port (17000). See Running in production for a full docker run example.
Quick start
-
Write a sample config file:
-
Edit
config.jsonto list your SHiP nodes. A barehost:portis dialed asws://; usewss://host:portwhen the node serves TLS. Adjust the bind address/port and intervals. -
Validate the config and test upstream connectivity:
-
Run the proxy:
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. An upstream endpoint is host:port, ws://…, or wss://…. A bare host:port is dialed as ws://, which is what existing configs already do.
listen_address(string, required in single-port mode; default"0.0.0.0"withlisteners) — Bind address for client connections (e.g.0.0.0.0). Acts as the default bind address forlistenersandmetrics_address.listen_port(u16, required in single-port mode, non-zero; sample17000) — Port for client connections in single-listener mode. Cannot be combined withlisteners.upstream_reconnect_ms(u64, required,> 0) — Milliseconds between upstream reconnection attempts.upstream_monitoring_ms(u64, required,> 0) — Milliseconds between block-progress logging and staleness checks.upstream_status_ms(u64, required,> 0) — Milliseconds between status requests sent to each upstream. Only one request is in flight; a reply that misses this deadline marks the upstream offline.servers(array, required in single-port mode,≥ 1enabled) — List of upstream SHiP nodes (see below). Mutually exclusive withlisteners.chain_id(string, optional) — 64-character hexchain_idin single-port mode; upstreams reporting a differentchain_idare rejected and kept offline. A status that omitschain_idstays online unlessrequire_chain_idis set: onget_status_result_v0that field is an optional extension, and older nodeos builds (FIO mainnet/testnet and other EOSIO 2.0-based nodes) never send it. Even when unset, any upstream whose reportedchain_idmismatches the rest of its pool is automatically rejected.require_chain_id(bool, optional, defaultfalse) — Reject and keep offline any upstream whose status omitschain_id. Requireschain_id. The default accepts that status and logs one warning per upstream thatchain_idcannot be verified (the warning is cleared if a later status reports achain_id, then logged again on the next omission). Cannot be combined withlisteners; set it on each listener instead.listeners(array, required in multi-listener mode,≥ 1entry) — Named ingress listeners mapping distinct ports to isolated SHiP pools (see Multi-network / multi-port routing).max_connections(usize, optional, default10000) — Max concurrent client connections; excess are rejected (backpressure). Acts as the default per-listener cap whenlistenersis used.handshake_timeout_ms(u64, optional, default10000) — Client WebSocket handshake timeout;0disables it. The upstream monitor andconfig testuse this same bound for connect and the first read, falling back to 10s when it is0.idle_timeout_ms(u64, optional, default0/ disabled) — Close a connection idle (no data in either direction) for this long.max_message_bytes(usize, optional, default268435456/ 256 MiB) — Max WebSocket message size on proxied upstreams and monitor connections.max_client_message_bytes(usize, optional, default65536/ 64 KiB) — Max WebSocket message and frame size accepted from a client. Omitting the field keeps existing configs valid and applies this default.shutdown_grace_ms(u64, optional, default5000) — How long to wait for in-flight connections to drain on shutdown.metrics_address(string, optional, defaults tolisten_address) — Bind address for the health/metrics HTTP endpoint.metrics_port(u16, optional, disabled when unset) — Port for the health/metrics HTTP endpoint. Setting it enables the endpoint.
Each entry in servers is an object:
name(string, required) — Human-readable name used in logs and as theserverlabel on each/metricsseries for this upstream.endpoint(string, required) — Upstream ashost:port,ws://host:port, orwss://host:port. A barehost:portis dialed asws://. Endpoints must be unique within a listener's pool (see below).enabled(bool, required) — Whether the router may use this upstream. At least one enabled server is required.
wss:// is dialed with rustls (not OpenSSL). The router verifies the upstream certificate against the operating-system trust store. The published container image installs CA certificates, so public CAs work there. For a private CA, install it into that store, or set SSL_CERT_FILE / SSL_CERT_DIR to PEM certificates — when either variable is set, those paths replace the OS store. Unknown schemes (https://, http://, …) are rejected. Uniqueness is the dial target: 127.0.0.1:8080 and ws://127.0.0.1:8080 are the same upstream, and so are ws://host and ws://host:80. ws:// and wss:// to the same host are different. The client-facing listener stays plaintext ws://.
Client requests are capped by max_client_message_bytes (default 64 KiB) on both the WebSocket message size and the frame size. Upstream block payloads still use max_message_bytes. While an upstream failover is in progress, client frames that cannot be forwarded are buffered up to 64 KiB per session. Each buffered frame counts as at least one byte, so empty frames cannot grow the queue without a bound. Exceeding that cap closes the session with WebSocket close code 1008 (policy violation).
Sample config.json
Multi-network / multi-port routing
To expose multiple ingress ports for different chains (each backed by its own redundant SHiP pool), you can either:
-
Use
listenersin a singlefleet-routerprocess — each entry inlistenersbinds its own port and maintains an isolated upstream pool, monitor loop, and connection semaphore:name(string, required) — Unique listener/network identifier (no/or whitespace). Used in logs,/metrics(listener="<name>"), and/ready/<name>.listen_port(u16, required, non-zero) — Ingress TCP port for this listener. Must not collide with other listeners ormetrics_port.listen_address(string, optional) — Bind address override; defaults to top-levellisten_address(or"0.0.0.0").max_connections(usize, optional) — Per-listener concurrent client cap; defaults to top-levelmax_connections.chain_id(string, optional, recommended) — 64-character hexchain_idfor this network; upstreams reporting a differentchain_idare rejected and kept offline. A status that omitschain_idstays online unlessrequire_chain_idis true, because older nodeos builds never send that optionalget_status_result_v0extension. Whenchain_idis omitted from the config,fleet-routerlogs a startup warning and trusts the first currently-online upstream in the pool as the referencechain_id(if a misconfigured wrong-chain node answers first, the remaining nodes will be rejected until it goes offline). Always pinchain_idin multi-network setups.require_chain_id(bool, optional, defaultfalse) — Whentrue, a status that omitschain_idis rejected and the upstream stays offline. Requires this listener'schain_id. The default keeps older SHiP nodes (FIO and other EOSIO 2.0-based builds) online and logs a one-time warning thatchain_idcannot be verified for that upstream.servers(array, required,≥ 1enabled) — Redundant SHiP nodes belonging to this network. Referencing the sameendpointacross two different listeners is allowed (e.g. a dedicated VIP vs. a public port) and spawns an independent upstream monitor per listener.
-
Run one
fleet-routerinstance per network for process/container-level isolation — seedeploy/docker-compose.multi-network.ymlanddeploy/fleet-router@.service.
Usage
fleet-router config init <path> Write a sample config file to <path>.
fleet-router config test <path> Parse and validate a config, then test upstream connectivity.
fleet-router run [--config <path>] Run the proxy.
fleet-router check [--port <port>] Exit 0 if 127.0.0.1:<port> accepts TCP (default 17000).
fleet-router --version Print the version.
fleet-router --help Print help.
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):
# Everything at debug
RUST_LOG=debug
# Debug for fleet-router, info for everything else
RUST_LOG=fleet_router=debug,info \
Health and metrics endpoint
Set metrics_port (and optionally metrics_address) to enable an HTTP endpoint. It serves GET requests on:
/health,/healthz—200while the process is running./ready,/readyz—200if at least one upstream is online (or, inlistenersmode, if every listener has at least one upstream online), otherwise503./ready/<listener>,/readyz/<listener>—200if the named listener has at least one online upstream,503if none are online, or404if the listener name is unknown./metrics— Prometheus text exposition of router and upstream state.
Exposed metrics include (each per-upstream series carries server="<name>" from the configured server name; in multi-listener mode, each upstream series also carries listener="<name>"):
fleet_router_up
fleet_router_upstream_up{endpoint,server}
fleet_router_upstream_stale{endpoint,server}
fleet_router_active_connections{endpoint,server}
fleet_router_upstream_trace_begin_block{endpoint,server}
fleet_router_upstream_chain_state_begin_block{endpoint,server}
fleet_router_upstream_chain_state_end_block{endpoint,server}
fleet_router_upstream_finality_data_begin_block{endpoint,server}
fleet_router_upstream_finality_data_end_block{endpoint,server}
fleet_router_up is a process gauge and has no labels. Label values escape \, ", and newlines per the Prometheus text format.
trace_begin_block, chain_state_begin_block, and finality_data_begin_block are the first block of each SHiP range on that upstream. The gauges are always emitted. 0 is the value before a status result arrives. finality_data_begin_block (and finality_data_end_block) stay 0 on pre-Savanna nodes that only answer get_status_result_v0 and never report a finality range.
Running in production
Do not expose
fleet-routerdirectly to the public internet. See Security and limitations.
systemd
For a single config file (either single-port or multi-listener):
[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
To run one isolated process per network with systemd template units, copy deploy/fleet-router@.service to /etc/systemd/system/fleet-router@.service, place each chain's config at /etc/fleet-router/<chain>.json, and start each unit (systemctl enable --now fleet-router@eos fleet-router@wax).
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
The image runs as a non-root user. Its HEALTHCHECK runs fleet-router check, which dials 127.0.0.1:17000. For multi-container deployments (one container per network), see deploy/docker-compose.multi-network.yml.
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,max_client_message_bytes): sizemax_connectionsfor your expected concurrency (excess connections are rejected, not queued); setidle_timeout_msto reap dead clients; lowermax_message_bytesonly if you are sure upstream blocks fit.max_client_message_bytescaps client frames (default 64 KiB). The per-session failover buffer is capped at 64 KiB, and the session is closed if a client exceeds it.
Security and limitations
- The client listener is plaintext
ws://and unauthenticated — anyone who can reach the port can stream data. Put TLS and access control for clients on a reverse proxy in front of fleet-router. - Upstream connections default to plaintext
ws://. Setendpointtowss://host:portto dial that node with TLS; the certificate is verified with rustls against the OS trust store. rs_abieos(pure Rust) parses untrusted upstream data; 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-routerdirectly to the public internet.
Contributing
Contributions are welcome. Please read 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. Please do not open public issues for security reports.
Changelog
See CHANGELOG.md for release notes (Keep a Changelog format).
Code of conduct
This project follows the Code of Conduct.
License
Licensed under the MIT License. © EOS Rio.