Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

substrate-node-probe

A small Rust client that connects to a Substrate node over WebSocket, verifies which chain the node is actually serving, reports its identity and health over JSON-RPC, and can follow new blocks as they are produced.

Previously substrate_handshake. The old URL redirects.

$ substrate-node-probe --node-address wss://rpc.polkadot.io \
               --genesis-hash 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3

INFO  Connecting to node at wss://rpc.polkadot.io
INFO  Genesis hash verified: 0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3
INFO  Received response for request id 1: {"id":1,"jsonrpc":"2.0","result":"Parity Polkadot"}
INFO  Received response for request id 2: {"id":2,"jsonrpc":"2.0","result":"Polkadot"}
INFO  Received response for request id 3: {"id":3,"jsonrpc":"2.0","result":"1.24.0-660acefe665"}
INFO  Node health: 65 peer(s), syncing=Some(false)
INFO  Node information queried!

What it does

  1. Connects to the node's JSON-RPC endpoint over WebSocket (ws:// or wss://).
  2. Checks chain identity by asking for the hash of block 0 via chain_getBlockHash and comparing it to the --genesis-hash you supplied. A node on a different chain is rejected and the client exits non-zero without querying anything further.
  3. Queries node informationsystem_name, system_chain, system_version, system_health and chain_getHeader — matching each response to its request by JSON-RPC id, since nodes are free to answer out of order (and do). All five go out before any reply is read, so the step costs one round trip rather than five.
  4. Follows new blocks, optionally — with --follow N it subscribes via chain_subscribeNewHeads, reports headers as the node pushes them, then unsubscribes.
  5. Reports, either as logs through env_logger (RUST_LOG=debug shows the full exchange) or as a single JSON object with --json.

Machine-readable output

--json prints one object to stdout. Logging stays on stderr, so stdout carries nothing but the report:

$ substrate-node-probe --node-address wss://rpc.polkadot.io --follow 2 --json 2>/dev/null
{
  "endpoint": "wss://rpc.polkadot.io",
  "ok": true,
  "genesis_hash": "0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3",
  "genesis_verified": true,
  "rpc_latency_ms": 58,
  "name": "Parity Polkadot",
  "chain": "Polkadot",
  "version": "1.24.0-660acefe665",
  "peers": 69,
  "is_syncing": false,
  "should_have_peers": true,
  "best_block": 32264078,
  "heads_followed": 2
}

Three properties worth relying on:

  • A failed run still prints a report"ok": false with a failure and an error, plus whatever was gathered before the failure. A probe that goes silent exactly when the node breaks is no use to whatever is parsing it. A genesis mismatch, for instance, still reports the hash the node actually serves.
  • Fields the node did not answer are omitted, not null"peers": 0 means an isolated node; a missing peers means the node never said. system_health is not exposed everywhere, and a refusal costs only its own fields rather than the whole report.
  • Nothing requires parsing prose. The error string is for humans and is free to be reworded; failure is the part that is safe to alert on.

genesis_verified is true only when --genesis-hash was supplied and matched; without the flag the hash is reported but nothing about it is proven. should_have_peers is what makes peers: 0 interpretable — it is false on a dev chain running alone. best_block is the node's current height, which is how you tell a stuck node from a healthy one; the probe reports it but cannot judge staleness on its own, because a Substrate header carries no timestamp — compare it across runs, or against a second node.

Failure kinds

On failure, failure carries one of these. They are a stable contract; the prose in error is not.

failure Meaning
config The command line asked for something impossible. The node is blameless.
connect The connection was never established.
timeout A wait expired. The node may be up but is not answering in time.
transport The connection failed or closed part-way through.
protocol The node answered with something unusable — bad JSON, or a result of the wrong shape.
rpc_error The node returned a JSON-RPC error for a call the probe needs.
genesis_mismatch The node is serving a different chain than you required.
requirement_unmet The node is reachable and correct, but failed a --require-* bar.

The distinction that motivates this: connect and requirement_unmet are both "the probe exited 1", but one means your monitoring cannot see the node and the other means the node is up and telling you it is unhealthy. Those usually want different alerts.

case "$(substrate-node-probe --node-address "$RPC" --require-peers 1 --json 2>/dev/null | jq -r '.failure // "ok"')" in
  ok)                page=none ;;
  requirement_unmet) page=degraded ;;
  connect|timeout)   page=unreachable ;;
  *)                 page=investigate ;;
esac

Why WebSocket

Steps 1–3 don't need it — request/response would work as well over HTTP POST. --follow is the part that does: a subscription has the node push frames unprompted for as long as the connection lives, which is what the transport is actually for.

$ substrate-node-probe --node-address wss://rpc.polkadot.io --follow 3

INFO  Subscribed with id JfjLVdZ5TGesJIVX
INFO  New head #32263282 parent=0xb2ddbc8a89c37c57b07136cf2c997c8074b0c7de309f19eae00a990c2e3bfe8c
INFO  New head #32263283 parent=0x52aa1f4c48e680c82f9a5838f8e1f16f546e427b03c842e572e4b3225713e766
INFO  New head #32263284 parent=0x0e0c80ba8389731ff05c28a2810a3093d018216b12bbe2fd11cc70982c093397
INFO  Unsubscribed after 3 header(s)

Scope

This talks to a node's RPC endpoint, not its peer-to-peer port. Verifying the genesis hash confirms which chain you are connected to; it is not peer authentication, which is a libp2p protocol on port 30333 requiring Noise, multistream-select and yamux.

The HandshakeMessage struct in src/scale.rs is a worked example of SCALE encoding — Substrate's wire codec — kept with a round-trip test. It is deliberately not sent to the node, because an RPC endpoint has no notion of it.

Try it in a browser

substrate-node-probe.onrender.com — runs the real binary emilbob.github.io/substrate-node-probe — runs in your tab

The same checks against a live node, with results filling in as they resolve. Source is web/index.html — one self-contained file, no build step.

The page runs the checks one of two ways, and always says which:

Engine Where What actually runs
Rust Render The real binary, server-side. The report is its output, unmodified.
JavaScript GitHub Pages, or any static host A reimplementation of the checks in the browser tab, connecting straight to the node. Nothing is proxied.

Render's free tier sleeps after ~15 minutes idle, so the first request after a quiet spell takes a while to wake. The Pages copy has no backend to wait on and is always instant — which is the honest trade between the two.

The page asks /api/health on load; if a backend answers, it offers the switch and defaults to Rust. On a static host there is nothing to switch to, so it stays in browser mode and says so rather than implying the binary ran.

The JavaScript engine is a genuine reimplementation, not a shim — same request ids, same pipelined calls, same per-step deadlines, same eight failure kinds, same rule that an unprovable requirement fails. It exists so the page is useful without a server, not to pretend to be the binary.

Locally, either way:

python3 -m http.server -d web 8000              # static, JavaScript engine only
cargo run --features server --bin serve         # http://127.0.0.1:8080, both engines

Note that a page served over https cannot open a ws:// connection to 127.0.0.1 — browsers block that as mixed content — so probing a local dev node from the hosted page is not possible. Use the CLI, which has no such restriction.

Hosting the real binary

serve is a small HTTP wrapper around the same probe() the CLI calls: GET /api/health, POST /api/probe, and the static page on everything else so the two share an origin.

cargo run --features server --bin serve
curl -s localhost:8080/api/probe -H 'Content-Type: application/json' \
  -d '{"endpoint":"wss://rpc.polkadot.io","require_peers":1}' | jq

It is feature-gated, so a plain cargo build still produces only the CLI without compiling a web stack it will never call.

render.yaml provisions it on Render from the DockerfileNew → Blueprint → pick this repo, nothing to configure by hand. That is what runs at substrate-node-probe.onrender.com.

Running it for strangers is not running it for yourself

An endpoint field that becomes an outbound connection is server-side request forgery. guard.rs resolves the host and refuses anything that is not on the public internet — loopback, RFC1918, link-local (169.254.169.254 is where cloud metadata lives), carrier-grade NAT, multicast, and the IPv4-mapped IPv6 forms of all of them. The server also caps follow, shortens the header wait, limits the request body and bounds the whole request.

None of this applies to the CLI, which defaults to ws://127.0.0.1:9944 and must keep working against a local dev node. The difference is not the address, it's who is asking.

One limit worth stating plainly: the guard resolves the name and the WebSocket client resolves it again, so a record that changes in between could still slip past (DNS rebinding). Closing that needs the connection pinned to the address already checked, which the client does not expose. The service therefore also holds no secrets and has nothing worth reaching.

Layout

File Holds
src/lib.rs The probe itself, so the CLI and the server run the same code.
src/main.rs The CLI flags.
src/bin/serve.rs The HTTP wrapper, and the limits that apply only to strangers.
src/rpc.rs The transport: opening the WebSocket, request ids, timeouts, reading frames. Knows nothing about chains.
src/probe.rs The JSON-RPC calls — genesis hash, identity, health and height, following heads.
src/report.rs The findings, their JSON shape, and the --require-* checks.
src/error.rs The failure taxonomy.
src/guard.rs The SSRF check. Used by the server only, never the CLI.
src/scale.rs The SCALE codec example.
web/index.html The browser page, and the JavaScript engine.

Requirements

  • Rust and Cargo (rustup.rs)
  • A Substrate node to talk to — either a public endpoint or a local one (see below)

Usage

cargo run -- [--node-address <url>] [--genesis-hash <hex>]
Flag Default Meaning
--node-address ws://127.0.0.1:9944 WebSocket RPC endpoint. wss:// is supported.
--genesis-hash (none) Hash the node must report, with or without a 0x prefix. Omit to report the node's genesis hash without enforcing one.
--follow <COUNT> (none) Follow this many new block headers after querying, then unsubscribe and exit. Omit to exit straight after the query.
--require-peers <N> (none) Fail unless the node reports at least N connected peers.
--require-synced (off) Fail if the node is still syncing.
--json (off) Print the findings to stdout as one JSON object. Logs stay on stderr.
--connect-timeout <SECS> 10 Seconds to allow for the connection.
--rpc-timeout <SECS> 10 Seconds to allow the node to answer a request.
--head-timeout <SECS> 120 Seconds to allow for each pushed header under --follow. Raise it for a slow parachain.

Exit code is 0 on success and 1 on any failure, so it works as a health check directly:

substrate-node-probe --node-address wss://rpc.polkadot.io \
  --require-peers 1 --require-synced || alert "polkadot rpc is unusable"

Requirements

Without --require-* the exit code only means the node answered. A node can answer every query correctly, on the right chain, and still be useless — connected to nobody, or serving stale state while it catches up. The flags are what make the exit code mean the node is usable:

ERROR node has 0 peer(s), --require-peers demands 1
ERROR node is still syncing

A requirement that cannot be evaluated fails. If the node will not say how many peers it has — system_health is not exposed everywhere — then --require-peers has not been met, and the probe says so rather than passing. Treating silence as success is how a health check comes to certify a broken node.

Requirements are judged before --follow, so a node that has already failed is not watched for another block.

Public endpoints

These genesis hashes were verified against the live networks:

Chain Endpoint Genesis hash
Polkadot wss://rpc.polkadot.io 91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3
Kusama wss://kusama-rpc.polkadot.io b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe
Westend wss://westend-rpc.polkadot.io e143f23803ac50e8f6f8e62695d1ce9e4e1d68aa36c1cd2cfd15340213f3423e

Pointing the Polkadot hash at Kusama is rejected, which is the whole point of the check:

ERROR genesis hash mismatch — expected b0a8d493…3dafe, node reports 91b171bb…ce90c3

A local node

The old substrate-node-template has been superseded by the Polkadot SDK solochain template:

git clone https://github.com/paritytech/polkadot-sdk-solochain-template
cd polkadot-sdk-solochain-template
cargo build --release
./target/release/solochain-template-node --dev

Then, in another terminal:

cargo run          # defaults to ws://127.0.0.1:9944

A dev chain's genesis hash depends on its chainspec and runtime build, so there is no fixed value to check against — run without --genesis-hash, note the hash the client reports, and pass it back if you want the check enforced on later runs.

Development

cargo test                      # unit tests, incl. mock-node integration
cargo test -- --ignored         # plus the live-network probe (see below)
cargo clippy --all-targets      # lints
cargo fmt --check               # formatting

Tests run against an in-process mock WebSocket node, so they need no network and no running Substrate node. Timeouts are injectable, so the timeout paths are covered with short real durations and the whole suite finishes in well under a second.

The live test

The mock node answers whatever the test file tells it to, so it can only prove the client is self-consistent — it would keep passing if an RPC method were renamed, a result shape changed, or the wss:// and rustls path broke. One #[ignore]d test runs the whole probe against wss://rpc.polkadot.io and asserts on the real answers.

It is excluded from cargo test and never runs on a pull request, because it depends on a third party being up: a PR failing because someone else's endpoint blinked is the false alarm that teaches you to ignore CI. live-probe.yml runs it weekly instead, retrying twice before reporting — one dropped connection is noise, three in a row is a signal.

Notes

  • TLS uses rustls with the ring provider, so the build needs no OpenSSL system libraries.
  • Every wait is bounded, so a node that accepts the socket and goes silent produces an error rather than a hang. The bounds apply to a step as a whole, not to each frame within it: the read loops skip frames they do not recognise, and a per-frame clock would let a node emitting one unrelated frame just inside the limit keep the probe waiting forever — every individual wait short, the total unbounded. Waiting for a pushed block header gets a fresh budget per header, since that waits on the chain's block time rather than on the node being responsive.

Releases

Packages

Used by

Contributors

Languages