On this page
Unreleased
Fixed
- Make
config testvalidate handshake ABIs with the same parser as the running router. Valid Wiresysio::abi/1.*handshakes no longer reportInvalid ABI, and malformed payloads containingeosio::abiare rejected with the parser error.
Documentation
- Publish the operator references on the website with shared navigation, installation and Wire Network upgrade guides, and release notes. Build pages from Markdown in CI, and update the homepage and frozen-history guide for TLS and config reload.
0.8.1 - 2026-10-22
Fixed
- Accept Wire Network's
sysio::abi/1.*SHiP handshake by using the publishedrs_abieos0.7.0 Rust backend. Earlier builds rejected this ABI version and repeatedly reconnected to otherwise reachable Wire upstreams. - Include the ABI parser error in upstream monitoring warnings.
0.8.0 - 2026-10-21
Added
- Configuration reload and JSON editing in the dashboard. Authenticated API endpoints validate drafts, apply external file edits, or atomically save and apply changes, with runtime and file revision checks. Listener and upstream topology, client TLS, chain API ports and dashboard settings can change without restarting. Established connections retain their previous configuration and failover pool until they close; pending drains appear in Configuration and Sessions.
- Client-facing TLS termination (
tlsat the top level, per listener, or onchain_api) for both SHiP WebSocket listeners (wss://) and the/v1/chain/*HTTP chain API (https://). Supports PEM certificate chains (cert_file) and PKCS#8/PKCS#1/SEC1 private keys (key_file), multi-domain SNI (sni: [{ server_names, cert_file, key_file }]) with exact and*.domainwildcard matching, and automatic zero-downtime certificate hot-reload when files change on disk. - HTTP-only live upstream support for
chain_api: live servers (role: "live") may omitendpointwhenapi_endpoint(or its aliashttp_endpoint) is set, allowing dedicated/v1/chain/*RPC nodes without a SHiP socket to participate inget_infopolling, state reads, block lookups, and transaction pushes.
Changed
- The dashboard now reads status, sessions, events and chart samples through one revisioned push stream. It no longer polls sessions or fetches history and event backfill separately. All panels apply each revision together; reconnects and slow clients receive a complete resync. One shared publisher coalesces lifecycle updates and samples counters once per second. Existing REST read endpoints and the original SSE stream remain available for API consumers.
Fixed
- An invalid or missing TLS certificate/key file no longer blocks valid renewals for other domains on the same listener. Failed pairs retain their previous certificate and retry on the next poll.
- Updated TLS certificate verification and bundled roots to reject distrusted emSign intermediates.
- The dashboard could alternate which synchronized upstream appeared behind
after a node restarted and its status polls shifted relative to its peers.
Head lag now compares each fresh chain-head reply with peer heads known before its
request, instead of comparing samples collected at different times. Real
lag is still reported; differences shorter than a polling interval may not
be observed. Both the Head column and the graph's head marker use this same
measurement, while the graph's history range keeps its exact reported bounds.
The JSON API adds
head_lag_blocks,chain_head_block, andhead_sample_age_ms. Reload an open dashboard tab after upgrading to load the corrected graph.
0.7.0 - 2026-10-18
Upgrade notes
This release replaces the per-connection proxy with a session actor and a routing planner. A config without frozen servers still runs as a pool listener, but pool listeners behave differently in the ways below, so upgrading an existing deployment is not a no-op. Read these first.
Config
max_connections, at the top level and per listener, must be at most 1000000. A larger value fails validation. Apart from that, existing config files stay valid, and a listener without frozen servers stays a pool listener.
Status replies
- Pool listeners now answer
get_status_request_v0and_v1themselves, with the fleet's head, LIB and block ranges, instead of relaying one upstream's reply. Only until the fleet head and LIB are first known (normally right after startup) is a request relayed to the session's upstream, when a link is up. After that the router answers itself, even while every upstream is offline, with the last known head and LIB. - Each range is the top contiguous part of the listener's coverage, and that coverage is sticky: an upstream that goes offline, or is disabled from the control API, still counts. Its begin stays in status replies, and a live node's range still runs to the fleet head, so status can advertise history that no online upstream holds, such as the begin of a full-history node that is down. When the coverage has a hole, only the part above the hole is reported.
chain_idin these replies is the configured one, else one an online upstream reported. With neither, a v1 reply carries zeros and a v0 reply omits the field. Withchain_idconfigured, v0 replies always include it. Setchain_idon pool listeners whose clients check it.- A status request that is neither answered nor relayed within
handshake_timeout_ms(10 s when0) closes the client with 1011fleet status unavailable. A client that sends a status request while 1024 are already waiting or in flight is closed with 1008too many queued status requests.
Block results
- Pool listeners rewrite head and LIB in block results and relayed status
replies, so they never go below what the session was already sent (LIB is
capped at head). After a status reply, or after a failover to a node that
is behind, results carry that higher head and LIB, not the serving node's
own. This cannot be turned off for a pool listener.
fleet_router_patched_results_totalcounts the rewritten frames. - After a failover, the resume request lists the reversible blocks the
client already received as
have_positions. An upstream on another fork then rewinds, and the client can receive a block number it already had, with a new id, as it would from a single node. 0.6.0 dropped those blocks. - WebSocket ping and pong are no longer relayed between client and upstream. The router answers a client's pings itself, so a reply no longer shows that the upstream is alive. Upstream Text and Ping frames are not forwarded to the client.
Failover and client closes
- Two new checks are on by default.
upstream_stall_timeout_ms(60 s) fails over an upstream that sends nothing for that long while it owes blocks; replies to the router's pings do not count.live_lag_blocks(1200) makes a live node more than that many blocks behind the fleet head rank like a stale one when a session picks an upstream for a block above that node's head; a session already on it is not moved by this rule. Set either to0to turn it off. The stall check fails a live upstream over only when another online upstream can serve the next block. A live upstream that none could replace is kept, with one warning, until it has been silent for three times the timeout, and is then redialed;idle_timeout_msand the 5 s rule below still fail it over sooner. A frozen upstream is failed over at the first stall, with a strike. - A third check has no setting: when the monitor has marked a session's
upstream offline, the session fails over after 5 s without a data byte
while it owes blocks, even with
upstream_liveness_timeout_msat0and even if the upstream still answers pings. It is counted asfleet_router_failovers_total{reason="offline"}and strikes no upstream. - Some failures now close the client instead of failing over. An upstream
message above
max_message_bytescloses it with 1011block K exceeds max_message_bytes, where K is the client's next block, and strikes no upstream. Before, it was a failover with a strike, and five of them at the same block closed the client with 1011block K unavailable on all upstreams. A malformed client request closes the client with 1007invalid requestinstead of being forwarded. In a pool listener an unknown request variant is still forwarded, but if the upstream then drops the connection within 5 s, the client is closed with 1003unsupported requestinstead of failing over. - The opposite also changed: an upstream Close frame is no longer forwarded to the client, which used to end the client's connection. The session now fails over and the client stays connected.
- If an upstream resumed after a failover starts past the client's next
block and another upstream has that block, the session moves to it and
avoids the first one for 30 s. A jump that no other upstream can fill is
forwarded as before, now with a warning, and counted in
fleet_router_upstream_sequence_jumps_total. idle_timeout_msfails over an upstream only while it owes blocks, so a client waiting at the tip no longer causes reconnects. An HTTP 503 at the WebSocket upgrade makes the session avoid that upstream for 2 s without marking it offline.- The upstream liveness check now counts bytes as they arrive, so the 0.6.0
advice to raise
upstream_liveness_timeout_msfor large blocks whose bytes are already flowing on a slow link no longer applies. A block whose first byte takes longer thanupstream_stall_timeout_msstill triggers the stall check above. - A live result whose
prev_blockdoes not continue the last block the client got now fails over when another upstream covers the block, counted asfleet_router_failovers_total{reason="discontinuity"}with a warning that gives both ids. When no other upstream can serve it, it is forwarded and logged as an error. 0.6.0 forwarded it unchecked.
Requests and upstream connections
- Any
get_blocks, including one sent mid-session, is checked before it is forwarded. It is moved to another upstream when the session's upstream lacks the start block in a log the request asks for (or is quarantined at it, or disabled from the control API), or lags more thanlive_lag_blocksbehind the fleet head for it, and another upstream does not. Priority and load alone never move a request. Before, only a request for finality data the upstream lacked, or at a block it was quarantined for, was moved. - A
get_blockssent while the previous request still has results in flight opens a new upstream connection (connect, upgrade and ABI read) instead of reusing the socket. A request sent after the previous one has finished reuses it. - A client ack that would take the session's credit past
u32::MAXleaves it atu32::MAX, and the upstream is acked only by what the credit rose. One Spring node wraps the sum instead. handshake_timeout_ms(10 s when0) now also bounds each session's upstream connect, upgrade and ABI read, and each request write to an upstream. A dial that runs out marks a live upstream offline; an HTTP 503 does not.
Metrics, dashboard and logs
fleet_router_active_connectionscounts an upstream's connection slots: links held, dials in progress, and a failed link's slot until its replacement is up, so a session in failover is counted on two upstreams. For pool listeners this matches 0.6.0. A stitched listener also counts pre-dialed standby links, and a stitched client that only asks for status holds none.- In the dashboard and
/api/v1/status, a listener'ssessionsnow counts client sessions (before, it was the sum of its upstreams' slots), and an upstream'ssessionsis the same slot count as the gauge. An upstream'sfailovers_totalthere includes reroutes, while the Prometheusfleet_router_failovers_totalleaves them out (fleet_router_reroutes_totalcounts them). - Dashboard
session_failoverevents give the reason as the metric label (unclean,clean,idle,silent,operator,stall,offline,uncovered,discontinuity,send) instead of 0.6.0's phrases, so anything that parses that text needs updating. Client closes for 1003, 1007, oversized messages and an unavailable status emit no event. A session is listed as soon as the client connects. - Logs: operator moves and drains log one WARN
upstream stream ended; attempting failoverper moved session, and every session ends with an INFOsession closedline. New WARN lines cover ABI differences within a pool, stalls (and a stalled live upstream kept because nothing can replace it), offline-and-silent failovers, sequence jumps, discontinuities that fail over and a missingchain_id, and a new ERROR line covers a result that does not continue the last delivered block when no other upstream can serve it.
Added
- Range-split fleets. A server can be marked
"role": "frozen": a frozen range server, an archive upstream that serves a fixed block range from archived state-history and block logs with no nodeos behind it. A listener with an enabled frozen server (or"topology": "stitched") presents its upstreams as one SHiP node whose history is the union of their ranges. The router sends the listener's ABI without dialing, answersget_statusitself, plans eachget_blocksby start block and requested data, hands the stream from one upstream to the next at range boundaries without counting it as a failure, connects to the next upstream ahead of the boundary, and replaces head and LIB in archive results so they never move backwards. Archived blocks whose upstreams are all down are held for up togap_wait_ms; a hole in the configured coverage closes the client with 1011block K unavailable on all upstreams. See "Range-split fleets" indocs/operations.md. - Per-server
role,range(the archive's declared coverage, used until it reports and while it is down) andmax_sessions; per-listenertopologyandstitching(history_preference,predial_blocks,gap_wait_ms,patch_head_lib,abi_file,abi_mismatch); globalupstream_stall_timeout_msandlive_lag_blocks. - Results from a frozen range server are checked against the archive contract (block sequence, ids, requested fields, credit, continuity at seams). A violation fails the session over to another upstream and counts as a block failure.
- A frozen range server without a block log is supported. Requests with
fetch_blockprefer an upstream that holds blocks and move to it with no strike; otherwise results are forwarded withoutblock. A frozen result withoutprev_block, right after an irreversible block the session delivered (at or below the LIB already sent to the client), gets it filled in from that block. example.stitched.config.json, a stitched listener with two archived ranges, two live nodes and a chain API.config test --strict: exits 1 when the config is invalid, a stitched listener's coverage has a hole or a frozen server did not report its ranges, or a chain API listener has archived blocks that no frozen server with anapi_endpointcovers (a replica without one passes while its twin has one).config testprints each stitched listener's coverage and each chain API listener's servers.GET /coverageand/coverage/<listener>on the metrics port (fleet tip, canonical ABI, per-log coverage and the upstreams of each segment), and/ready?scope=history, which also requires complete, online archive coverage. Plain/readystill means the tip can be served./metrics: per-upstreamfleet_router_upstream_trace_end_block,_head_block,_lib_block,_info{role},_ranges_known,_range_mismatch,_abi_info,_abi_compatible,_abi_match,_links_total,_dial_failures_total,_stalls_total,_sequence_jumps_total,_contract_violations_total,_no_block_log,_results_without_block_totaland_prev_blocks_filled_total; per listener the fleet head, LIB and head age, coverage, gap, offline and live-overlap gauges, sessions by state, and counters for status replies, handoffs, failovers by reason, reroutes, dropped duplicates, fork re-deliveries, keep-alives, credit overruns, patched results, holds, pre-dials, client protocol errors and too-large closes, withfleet_router_handoff_latency_seconds.- An opt-in HTTP chain API listener per listener (
chain_api), with a per-serverapi_endpoint. Block endpoints are routed by block number: irreversible blocks go to the frozen range server that holds them, falling back to live nodes (block_sourcepicks who goes first), and reversible blocks go to live nodes only.get_infoand read-only state calls are proxied unchanged to live nodes. Push and exec are off by default; a push is never resent. The router answers with nodeos-shaped errors, nodeos's 404 for unknown endpoints, and its ownget_supported_apis, and never invents a parameter error or an Unknown Block. Seedocs/chain-api.md. chain_api.max_response_bytes(off by default, otherwise at least 65536) bounds the upstream answers a chain API relays: a longerContent-Lengthgets502, and an answer without one is cut off once it passes the limit. A push's answer is relayed whole, since the node may have applied the push.- New metrics
fleet_router_api_*andfleet_router_upstream_api_*; new readiness scope/ready?scope=api; achain_apiblock per listener in/coverage. docs/chain-api.md, and sections on range-split fleets (including the cut-over to a live node started from a snapshot), failover and strikes, readiness scopes,/coverage, every new metric and suggested alerts indocs/operations.mdanddocs/observability.md.
Changed
- Pool routing checks each
get_blocksagainst the session's upstream before it is forwarded, and moves it to an upstream that serves it better when needed. An upstream must have the start block in every log the request asks for (deltas-only requests use the chain-state range), a live node counts as covering the next block at its head, lagging live nodes rank like stale ones,max_sessionsis honoured, and ties are broken by a per-session hash. - An upstream WebSocket close frame is no longer forwarded to the client; the session fails over and the client connection stays open.
idle_timeout_msfails over a silent upstream only while it owes blocks, so a client waiting at the chain tip no longer causes reconnects. On the client side it still closes a connection that sends nothing.- Malformed client requests close the client with WebSocket code 1007 instead of being forwarded. A request variant the router does not know is still forwarded in a pool listener; a stitched listener closes the client with 1003.
- An upstream that answers the WebSocket upgrade with HTTP 503 is treated as busy: the session avoids it for 2 s and it is not marked offline.
- The liveness check and the 1 s probe after the monitor loses an upstream count bytes as they arrive, so a large message on its way is not taken for silence. A silent upstream is marked offline only when it is a live node.
fleet_router_active_connectionscounts the connections sessions hold to an upstream, which in a stitched listener includes pre-dialed standby links; a stitched client that only asks for status holds none.- A control API drain or move counts only an upstream below its
max_sessionsand not quarantined at the session's next block. A moved session returns to the upstream it left when the target cannot take it, and a dial that lands on an upstream disabled meanwhile goes to another upstream when one can take the session. Disable, drain, priority and weight also apply to the chain API. - In a stitched listener, a leg's dial in progress is replaced as soon as a
view shows its upstream can no longer serve the cursor: disabled while
another upstream can serve it, quarantined at it, or its range shrunk below
it. Otherwise its request end follows the view, and it is kept when another
upstream only ranks higher by then. When the monitor marks its upstream
offline, it is kept unless another online upstream that is not excluded
can serve the cursor, so a dead host no longer holds the leg until the dial
times out, and the upstream the session just failed off never takes the
leg back. When a bounded hold's
gap_wait_msruns out while a retry's dial that can still serve the cursor is on its way, that dial is the hold's last try, so the client is not closed while it runs; at the deadline, and on any later view, it still gives way to an online upstream that is not excluded and can serve the cursor. - A live seam whose first result does not continue the last delivered block
fails over when another upstream covers the block, with a warning that
gives both ids and
fleet_router_failovers_total{reason="discontinuity"}. When none does, the result is forwarded and logged as an error as before, and the upstreams left at that seam are no longer avoided. - Dashboard and
/api/v1/status: a listener'sreadyis what/ready/<listener>answers; a listener'ssessions(and the total) count client sessions, while an upstream'ssessionscount the connections held to it. The session list shows no upstream while a session has no link.
Fixed
- A client ack that takes the session's credit past
u32::MAXleaves it atu32::MAX, and the upstream is acked only what the credit rose by, so its own counter can no longer wrap and part from the session's. Such an ack made the session wait for blocks the upstream would never send (a false stall, and a strike on an archive) or forward results counted as overruns. One Spring node wraps the sum, which leaves a window that depends on the results still in flight. - Failover now resumes with the client's outstanding credit instead of its
original
max_messages_in_flight, and acks sent during the swap are no longer replayed on top of it. - Replayed blocks are de-duplicated by block number and id, dropped duplicates are refunded upstream with an ack, and fork re-deliveries are forwarded.
- Head keep-alive results from EOSIO 2.0 and Leap 3 nodes no longer move the resume block.
- A new
get_blocksmid-stream no longer inherits the previous request's resume point, a new request on a link that still has blocks in flight opens a fresh upstream link, and status requests are no longer lost during a failover. - Head and LIB in results never move backwards within a session.
- An upstream that stays connected and answers pings but stops sending the
blocks it owes is failed over after
upstream_stall_timeout_ms(a live upstream that no other online upstream could replace, after three times that). - A message larger than
max_message_bytescloses the client with 1011 and the reasonblock K exceeds max_message_bytesinstead of striking the upstream. - Session upstream dials now time out: the TCP connect with the WebSocket
upgrade, and then the wait for the upstream's ABI, are each bounded by
handshake_timeout_ms(10 s when0). Before, the TCP connect and the upgrade had no bound at all, and the wait for the ABI had none whenhandshake_timeout_mswas0.
0.6.0 - 2026-10-17
Upgrade notes
- The new upstream liveness check is on by default. A session pings an
upstream that has sent nothing for 2 s and fails over if nothing comes
back within 4 s. Clients keep their connection either way, but if single
blocks from your upstreams take seconds to transfer (very large blocks over
a slow link), raise
upstream_liveness_timeout_ms. - The dashboard and control API are off unless
dashboard.enabledis set. An existingmetrics_portserves the same health, readiness and metrics as before. /readyno longer counts an upstream that an operator disabled through the control API.- The health/metrics endpoint now runs on hyper. Paths and bodies are
unchanged; a non-GET request to an unknown path returns
404instead of405.
Fixed
- A session on an upstream that went silent without closing the connection
(cable pulled, VM frozen, firewall dropping packets) waited until the
operating system gave up on the TCP connection, often around 15 minutes,
and the client saw a stalled stream. Sessions now ping a quiet upstream and
fail over when nothing comes back within
upstream_liveness_timeout_ms(default 4 s), or within 1 s once the monitor has marked that upstream offline. The client keeps its connection and resumes at the next block. Reported by @Rossco99. - The upstream monitor stopped for good when an upstream closed its WebSocket cleanly, as nodeos does on a normal shutdown or restart. That upstream then stayed offline until fleet-router restarted. The monitor now reconnects.
Added
-
upstream_liveness_timeout_ms(default4000,0disables). See Fixed. -
Opt-in live dashboard at
/ui/, a plain/statuspage, and a JSON API under/api/v1(status, sessions, 15-minute history, events, and a server-sent events stream), served onmetrics_portwhendashboard.enabledistrue. Off by default, so upgrading does not expose anything new on an existing metrics port. Requested by @Rossco99. -
Token-protected control API (
dashboard.control_token_file,dashboard.control_token, orFLEET_ROUTER_CONTROL_TOKEN): disable, enable, or drain an upstream, change its priority and weight at runtime, clear its quarantines, and move or close a single session. A drain moves live sessions to other upstreams with the normal failover, so clients stay connected. A session moves only when another online upstream's range starts at or before its next block, so draining the only archive node leaves its history readers in place. Runtime changes are not written back to the config. -
dashboard.allowed_hosts: dashboard routes answer only requests addressed by IP,localhost, or a listed name, which blocks DNS rebinding. At most 32 live streams are open at once. -
New gauge
fleet_router_upstream_operator_disabled. -
Optional per-server
priority(default0, lower is preferred) andweight(default1). Among upstreams that are equally able to serve a request (same block coverage and freshness), the router uses the lowest priority tier, then the fewest connections per unit of weight. A high priority makes an upstream a last resort. Failover still moves off a failed preferred upstream to a backup that covers the block. New gaugesfleet_router_upstream_priorityandfleet_router_upstream_weight. Requested by @Rossco99. -
Static landing page under
site/for GitHub Pages. -
deploy/fleet-router.service, a systemd unit for one process and one config file, next to the per-networkfleet-router@.servicetemplate. -
"Run as a service" section in
docs/operations.md: step-by-step systemd install, Docker restart policy, a launchd plist for macOS, and running the Windows build under a service wrapper.
Changed
- The health/metrics endpoint now runs on hyper (via axum) instead of a
hand-written HTTP loop. Paths and response bodies are unchanged. Request
headers must arrive within 5 s, at most 256 connections are served at
once, and each connection serves one request (as before). A request to an unknown path with a method other than
GETnow returns404instead of405. fleet_router_upstream_priorityandfleet_router_upstream_weightreport the values in effect, which can differ from the config after a control API change.- Shorten
README.mdto an overview, install, and quick start. The full configuration reference, production notes, and metrics moved todocs/configuration.md,docs/operations.md, anddocs/observability.md. - Both systemd units set
LimitNOFILE=65536(the systemd default of 1024 open files capped the router at about 500 clients),RestartSec=2, and basic sandboxing (NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp).
0.5.0 - 2026-10-15
Fixed
- A mid-stream failover that keeps dying at the same next block is now bounded.
The bound counts only unclean socket drops (no WebSocket close frame). The
session prefers a different suitable upstream, waits 100 ms and then doubles
that pause up to 3.2 s, and stops after five such drops. The client receives
WebSocket close code 1011 with reason
block K unavailable on all upstreamsinstead of a silent stall, and one error names the upstream and the block. An idle timeout or a clean close still fails over and does not count. Reported by @eosusa (EOSUSA).
Added
- Opt-in
block_failure_quarantine(strikes,window_secs,ttl_secs,range), at the top level in single-listener mode or on each listener. After the configured number of unclean mid-get_blocksdrops at the same block, within the window and from any session, that upstream is skipped forK..K+rangefor the TTL. It is not marked offline. Defaults are 2 strikes, a 300 s window, a 600 s TTL, and a range of 1 block. Expired exclusions are removed when selection, a block check, or/metricslooks at them. /metricsexposesfleet_router_upstream_block_failures_total,fleet_router_upstream_quarantined, andfleet_router_upstream_quarantine_block(the quarantined block is the gauge value, not a label). Requested by @eosusa (EOSUSA).
0.4.0 - 2026-10-14
Security
- Bump locked
rustlsfrom 0.23.45 to 0.23.46. This clearsRUSTSEC-2026-0346,RUSTSEC-2026-0347,RUSTSEC-2026-0348, andRUSTSEC-2026-0349.
Fixed
- Pinning
chain_idno longer marks an upstream offline when its SHiP status omitschain_id. That field is an optional binary extension ofget_status_result_v0; older nodeos builds (FIO mainnet/testnet and other EOSIO 2.0-based nodes) never send it, so the previous check kept every such node permanently offline. A reportedchain_idthat does not match the pinned value or the rest of the pool is still rejected. When a status omitschain_id, the router logs a warning once per upstream thatchain_idcannot be verified, and logs it again only after a later status reports one. Reported by @eosusa (EOSUSA).
Added
/metricslabels every per-upstream series withserver="<name>"from the configured server name, in both single-port and multi-listener mode.endpointandlistenerlabels are unchanged. Label values escape backslash, double quote, and newline for the Prometheus text format. Requested by @eosusa (EOSUSA).- Optional
require_chain_id(falseby default), at the top level in single-listener mode or on each listener. Set it to restore the previous reject-when-missing behaviour. It is rejected by config validation unlesschain_idis set on that same scope. /metricsexposes each upstream's SHiP range start asfleet_router_upstream_trace_begin_blockandfleet_router_upstream_chain_state_begin_block, beside the existing chain-state end and finality block gauges.finality_data_begin_blockwas already exported. These gauges are always present.0means a status result has not arrived yet;finality_data_begin_blockalso stays0on pre-Savanna nodes that only reportget_status_result_v0. Requested by @eosusa (EOSUSA).
0.3.1 - 2026-10-14
Changed
- Reformat
README.mdconfiguration and route tables into bulleted definition lists so the documentation renders cleanly within crates.io's 575px layout without horizontal clipping or narrow multi-column wrapping.
0.3.0 - 2026-10-14
Security
- Cap client WebSocket messages separately from upstream messages with
max_client_message_bytes(default 64 KiB). The client limit applies to both the message size and the frame size, so an unauthenticated client can no longer force the router to accept upstream-sized frames. - Replace the per-session 1024-frame failover buffer with a 64 KiB byte cap. Each buffered frame counts as at least one byte. A session that exceeds the cap is closed with WebSocket close code 1008 (policy violation).
- Locked dependency updates clear
RUSTSEC-2026-0258(h20.4.20),RUSTSEC-2026-0285(rustls0.23.45), andRUSTSEC-2026-0190(anyhow1.0.104). Compatible updates also pick up tokio 1.53.2, serde 1.0.229, serde_json 1.0.154, clap 4.6.7, chrono 0.4.45, and futures 0.3.34.
Added
- Multi-ingress (
listeners) configuration for routing multiple local ports to isolated pools of redundant SHiP nodes (e.g. one ingress port per blockchain network) within a singlefleet-routerprocess. Existing single-port configs (listen_port+servers) remain unchanged. Supports optionalchain_idpinning (top-level or per-listener) and automatic pool-widechain_idconsistency checks so a misconfigured upstream on a different chain is kept offline. In multi-listener mode,/metricslabels each series withlistener="<name>",/readyrequires at least one online upstream in every listener, and/ready/<name>probes a single listener. Also addeddeploy/docker-compose.multi-network.ymlanddeploy/fleet-router@.servicefor multi-instance deployments. - Upstream
endpointvalues may include aws://orwss://scheme. A barehost:portstill dials plaintextws://, so existing configs are unchanged.wss://uses rustls with theaws-lc-rsprovider (not OpenSSL) and verifies the upstream certificate against the OS trust store.SSL_CERT_FILEandSSL_CERT_DIRreplace that store when set. Unknown schemes are rejected. Endpoint uniqueness follows the dial target, sohost:portandws://host:portare the same upstream. The client listener stays plaintext. - Optional configuration field
max_client_message_bytes. Configs that omit it remain valid and use the default. - Parse
get_status_result_v1and track each upstream's finality-data block range. The single in-flight status probe asks for v1 and falls back toget_status_request_v0when an upstream does not answer it. - Route
get_blocks_request_v1withfetch_finality_datato an upstream whose finality range covers the requested block. Other requests, and fleets that never report a covering range, keep trace-range selection.
Changed
- Building from source now requires a C compiler and CMake.
wss://links rustls withaws-lc-rs(the same crypto provider asreqwest0.13).rs_abieosstays on its pure-Rust backend, andlibclangis still not required. - Upgrade
rs_abieosfrom 0.5.0 to 0.6.0, still with default features off andrust-backendenabled. - Validate each upstream ABI with
AbiHandle::from_jsonand drop the handle as soon as the result is known. Client handshakes, failovers, and upstream monitors no longer share oneArc<Mutex<Abieos>>or register the ABI under account0.AbiHandleruns the same parse asset_abiand does not keep a context. A per-connectionAbieoswould retain that context and the parsed model for the whole session; fleet-router only needs the parse to succeed, so the handle is not stored. An interior NUL is still rejected before parsing. Client and upstream message caps are unchanged. - Minimum supported Rust version is now 1.99.
- The container image is built from digest-pinned
rust:1.99-trixieanddebian:trixie-slim. ItsHEALTHCHECKrunsfleet-router check, which probes127.0.0.1:17000without a shell. - GitHub Actions are pinned by commit SHA. The release workflow uses
softprops/action-gh-releasev3 (Node 24). Dependabot tracks Actions, Cargo, and Docker updates.
Fixed
- The zero-copy decoder no longer panics on a type mismatch or on an array
field whose element count would overflow the computed span.
From<ZCDValues>conversions are nowTryFrom,get_typereturnsOption, and the array branch ofdeserialize_withuses checked arithmetic and bounds-checked slices. Valid frames decode as before. - The hand-rolled SHiP status decoder rejects truncated and malformed upstream frames with an error instead of panicking on a short slice or a missing field. The monitoring loop logs the error and leaves that upstream's state unchanged. A rejected status frame does not count as the in-flight reply. The upstream ABI is applied without unwrapping the handshake buffer.
- Upstream monitors and
config testbound WebSocket connect and the first read with a timeout (handshake_timeout_ms, or 10s when that is 0) and applymax_message_bytes. A read error, a connect failure, or a missing status reply marks that upstream offline. An existing session still retries that upstream when no other node is online, so a single-node drop can reconnect (or hit the pending-byte cap) instead of closing immediately. - Status polling keeps a single
get_status_requestin flight and drops the session if the reply does not arrive withinupstream_status_ms. A stalled nodeos no longer accumulates queued status requests (Spring 1.2.3 ends a session once more than 100 are outstanding).
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 theRUST_LOGenvironment variable (defaults toinfo). - Graceful shutdown on
SIGINTandSIGTERM, with a bounded drain period (shutdown_grace_ms) and WebSocket close frames sent to connected clients. - Configuration validation with clear, actionable errors (
config testand 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-denysupply-chain configuration and CI (advisories, bans, licenses, sources), an MSRV check, dependency caching, and a tag-triggered release workflow (crates.io publish, cross-platform binaries, GHCR image).- Community health files:
CONTRIBUTING.md,SECURITY.md,CODE_OF_CONDUCT.md, issue templates, and a pull-request template.
Changed
- Build on the pure-Rust
rs_abieosbackend (rust-backend): fleet-router now compiles on Linux, macOS, and Windows (x86_64 and arm64) with no C/C++ toolchain,clang, orlibclang. - The CLI
--versionnow derives fromCargo.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 (
includeinCargo.toml);Cargo.lockis committed for reproducible builds. - Canonical default listen port reconciled to
17000across the sample config, Docker assets, and documentation. - The runtime Docker image runs as a non-root user and declares a
HEALTHCHECK.
Fixed
config initno 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.