diff --git a/.github/workflows/keep-green.yml b/.github/workflows/keep-green.yml index d4ffc3c..0280379 100644 --- a/.github/workflows/keep-green.yml +++ b/.github/workflows/keep-green.yml @@ -60,17 +60,25 @@ jobs: ran=1 cd "$(dirname "$ct")" cargo fmt --check - # Clippy blocks on PRs, where new warnings mean new code. On the - # scheduled run it only reports: a future toolchain adding a lint - # would otherwise fail the health check on untouched code, which is - # exactly the false alarm that trains you to ignore it. + # --all-features throughout: optional features are exactly the code + # nobody compiles locally, so without this the server binary rots + # unnoticed while the default build stays green. if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then - cargo clippy --all-targets -- -D warnings + cargo clippy --all-targets --all-features -- -D warnings else - cargo clippy --all-targets || echo "::warning::clippy reported warnings (non-blocking on a scheduled run)" + # Clippy only reports on the scheduled run: a future toolchain + # adding a lint would otherwise fail the health check on untouched + # code, the false alarm that trains you to ignore it. + cargo clippy --all-targets --all-features || echo "::warning::clippy reported warnings (non-blocking on a scheduled run)" fi - cargo build - cargo test + cargo build --all-features + cargo test --all-features cd - >/dev/null fi if [ "$ran" = 0 ]; then echo "No recognized toolchain — nothing to check."; fi + + # A broken Dockerfile only shows up as a failed deploy otherwise, by which + # point it is already on the default branch. + - name: Build the container image + if: hashFiles('Dockerfile') != '' + run: docker build -t ${{ github.event.repository.name }}:ci . diff --git a/Cargo.lock b/Cargo.lock index 8ab895b..6b7e11b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "aho-corasick" version = "1.1.3" @@ -106,6 +91,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[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.3.0" @@ -136,18 +127,55 @@ dependencies = [ ] [[package]] -name = "backtrace" -version = "0.3.73" +name = "axum" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -197,9 +225,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.6.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12916984aab3fa6e39d655a33e09c0071eb36d6ab3aea5c2d78551f1df6d952" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" @@ -340,6 +368,17 @@ dependencies = [ "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 2.0.119", +] + [[package]] name = "dunce" version = "1.0.5" @@ -381,6 +420,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[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 = "fs_extra" version = "1.3.0" @@ -393,6 +441,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.33" @@ -457,12 +514,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "gimli" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" - [[package]] name = "hashbrown" version = "0.14.5" @@ -475,12 +526,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - [[package]] name = "hex" version = "0.4.3" @@ -497,12 +542,187 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower 0.4.13", + "tower-service", +] + +[[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 = "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 = "impl-trait-for-tuples" version = "0.2.2" @@ -583,9 +803,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.155" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -603,6 +829,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.4" @@ -610,42 +842,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] -name = "miniz_oxide" -version = "0.7.4" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" -dependencies = [ - "adler", -] +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "mio" -version = "0.8.11" +name = "mime_guess" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ - "libc", - "wasi", - "windows-sys 0.48.0", + "mime", + "unicase", ] [[package]] -name = "num_cpus" -version = "1.16.0" +name = "mio" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ - "hermit-abi", "libc", -] - -[[package]] -name = "object" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" -dependencies = [ - "memchr", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -706,7 +926,33 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -736,6 +982,15 @@ dependencies = [ "portable-atomic", ] +[[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.17" @@ -857,12 +1112,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustls" version = "0.23.42" @@ -953,6 +1202,29 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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.7" @@ -996,14 +1268,20 @@ checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] name = "socket2" -version = "0.5.7" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.52.0", + "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" @@ -1014,6 +1292,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" name = "substrate-node-probe" version = "0.1.0" dependencies = [ + "axum", "clap", "env_logger", "futures-util", @@ -1025,6 +1304,8 @@ dependencies = [ "serde_json", "tokio", "tokio-tungstenite", + "tower-http", + "url", ] [[package]] @@ -1066,6 +1347,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tap" version = "1.0.1" @@ -1112,30 +1410,38 @@ dependencies = [ "syn 3.0.0", ] +[[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.38.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2caba9f80616f438e09748d5acda951967e1ea58508ef53d9c6402485a46df" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "backtrace", "bytes", "libc", "mio", - "num_cpus", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.3.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" dependencies = [ "proc-macro2", "quote", @@ -1168,6 +1474,19 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml_datetime" version = "0.6.6" @@ -1185,6 +1504,94 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[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 = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tungstenite" version = "0.24.0" @@ -1211,6 +1618,12 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[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.12" @@ -1223,12 +1636,30 @@ 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" @@ -1271,22 +1702,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -1298,67 +1720,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1371,48 +1760,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1428,6 +1793,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "wyz" version = "0.5.1" @@ -1437,8 +1808,85 @@ dependencies = [ "tap", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +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 2.0.119", + "synstructure", +] + +[[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 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[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 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml index 196af37..157aabb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,10 +6,34 @@ description = "Connects to a Substrate node over WebSocket, verifies which chain license = "MIT" repository = "https://github.com/emilbob/substrate-node-probe" +[[bin]] +name = "substrate-node-probe" +path = "src/main.rs" + +# The HTTP wrapper behind the hosted page. Feature-gated so a plain +# `cargo build` still produces just the CLI without compiling a web stack it +# will never call. +[[bin]] +name = "serve" +path = "src/bin/serve.rs" +required-features = ["server"] + +[features] +default = [] +server = ["dep:axum", "dep:tower-http"] + [dependencies] tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +url = "2" +axum = { version = "0.8", optional = true } +tower-http = { version = "0.6", features = [ + "fs", + "cors", + "limit", + "timeout", +], optional = true } hex = "0.4" # rustls rather than native-tls: no OpenSSL system dependency, so the build is # self-contained on CI. Without a TLS feature every wss:// endpoint is diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eb64150 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Builds the `serve` binary — the HTTP wrapper that lets the browser page run +# the real probe. The CLI is unaffected by any of this. + +FROM rust:1-slim-bookworm AS build +WORKDIR /src + +# Dependencies first, against a stub main, so that editing the source does not +# re-download and re-compile the whole tree on every build. +COPY Cargo.toml Cargo.lock ./ +RUN mkdir -p src/bin \ + && echo 'fn main() {}' > src/main.rs \ + && echo 'fn main() {}' > src/bin/serve.rs \ + && touch src/lib.rs \ + && cargo build --release --features server --bin serve \ + && rm -rf src + +COPY src ./src +# Cargo stats mtimes; the stub artifacts would otherwise be considered current. +RUN touch src/main.rs src/lib.rs src/bin/serve.rs \ + && cargo build --release --features server --bin serve + +FROM debian:bookworm-slim AS runtime + +# The probe speaks wss:// to public endpoints, so the image needs root +# certificates — without them every connection fails TLS verification and the +# cause is not obvious from the error. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Nothing here needs root, and a probe that dials arbitrary public addresses is +# exactly the sort of thing to run unprivileged. +RUN useradd --system --create-home --uid 10001 probe +USER probe +WORKDIR /app + +COPY --from=build /src/target/release/serve /usr/local/bin/serve +COPY web /app/web + +ENV WEB_ROOT=/app/web \ + RUST_LOG=info \ + PORT=8080 +EXPOSE 8080 + +CMD ["serve"] diff --git a/README.md b/README.md index e20fc89..59b0850 100644 --- a/README.md +++ b/README.md @@ -106,29 +106,64 @@ The `HandshakeMessage` struct in `src/scale.rs` is a worked example of SCALE enc **→ [emilbob.github.io/substrate-node-probe](https://emilbob.github.io/substrate-node-probe/)** -The same checks against a live node, with results filling in as they resolve. It is a **JavaScript reimplementation**, not this binary — a browser cannot execute a Rust program — but it speaks the same JSON-RPC, uses the same request ids and failure kinds, and applies the same rule that an unprovable requirement fails rather than passes. Connections go straight from your browser to the node you name; nothing is proxied. +The same checks against a live node, with results filling in as they resolve. Source is [`web/index.html`](web/index.html) — one self-contained file, no build step. -Source is [`web/index.html`](web/index.html), one self-contained file with no build step. To run it locally: +The page runs the checks one of two ways, and always says which: + +| Engine | When | What actually runs | +| --- | --- | --- | +| **JavaScript** | Static hosting, e.g. GitHub Pages | A reimplementation of the checks in the browser tab, connecting straight to the node. Nothing is proxied. | +| **Rust** | Served by `serve` (see below) | The real binary, server-side. The report is its output, unmodified. | + +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: ```bash -python3 -m http.server -d web 8000 # then open http://127.0.0.1:8000 +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 ``` -Deployed by [`pages.yml`](.github/workflows/pages.yml) on any push that touches `web/`. +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`](src/bin/serve.rs) 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. + +```bash +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`](render.yaml) provisions it on [Render](https://render.com) from the [`Dockerfile`](Dockerfile) — *New → Blueprint → pick this repo*. On Render's free tier the service sleeps after ~15 minutes idle, so the first request after a quiet spell is slow. + +### 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`](src/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. -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** means running the page from disk or `http://127.0.0.1`, or just using the CLI, which has no such restriction. +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/main.rs` | The CLI flags, and the order the steps run in. | +| `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 version — same checks, different language. | +| `web/index.html` | The browser page, and the JavaScript engine. | ## Requirements diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..edb18cf --- /dev/null +++ b/render.yaml @@ -0,0 +1,30 @@ +# Render blueprint. Point Render at this repo and it provisions the service +# from here rather than from settings typed into a dashboard, so the hosting is +# reviewable in the same place as the code. +# +# Deploy: render.com -> New -> Blueprint -> pick this repo. + +services: + - type: web + name: substrate-node-probe + runtime: docker + dockerfilePath: ./Dockerfile + plan: free + region: frankfurt + branch: master + # Only rebuild when something that ends up in the image changes; a README + # edit should not cost a container build. + buildFilter: + paths: + - src/** + - web/** + - Cargo.toml + - Cargo.lock + - Dockerfile + healthCheckPath: /api/health + envVars: + - key: RUST_LOG + value: info + # Deliberately absent: ALLOWED_ORIGIN. The page is served by this same + # service, so requests are same-origin and need no CORS allowance. Set it + # only if you point the GitHub Pages copy at this backend. diff --git a/src/bin/serve.rs b/src/bin/serve.rs new file mode 100644 index 0000000..c13b2af --- /dev/null +++ b/src/bin/serve.rs @@ -0,0 +1,188 @@ +//! An HTTP wrapper so the browser page can run the *actual* probe. +//! +//! The page shipped in `web/` re-implements the checks in JavaScript, because a +//! browser cannot execute this binary. Served from here it does not have to: +//! the page and this API are the same origin, the page notices, and every check +//! runs through [`substrate_node_probe::probe`] — the same code the CLI runs. +//! +//! Everything here that is not routing is about the difference between a tool +//! you run and a tool strangers run: the endpoint is validated as public before +//! it is dialled, the work is capped, and the whole thing holds no secrets. + +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use axum::{ + extract::Json, + http::{HeaderValue, Method, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use log::{info, warn}; +use serde::Deserialize; +use tower_http::{ + cors::CorsLayer, limit::RequestBodyLimitLayer, services::ServeDir, timeout::TimeoutLayer, +}; + +use substrate_node_probe::{ + guard, probe_to_report, + report::ProbeReport, + rpc::{Timeouts, RPC_TIMEOUT, SUBSCRIPTION_TIMEOUT}, + ProbeRequest, +}; + +/// Ceilings on what one anonymous request may ask for. +/// +/// The CLI has no such caps — you may wait two minutes a block on your own +/// machine if you like. Here every request occupies a connection on a small +/// instance, so the expensive knob is the one that gets clamped hardest. +const MAX_FOLLOW: u64 = 3; +const MAX_HEAD_WAIT: Duration = Duration::from_secs(30); +const MAX_REQUEST_BODY: usize = 4 * 1024; +/// Hard ceiling on a whole request, above whatever the probe's own waits allow, +/// so a wedged handler cannot hold a worker forever. +const REQUEST_DEADLINE: Duration = Duration::from_secs(75); + +#[derive(Debug, Deserialize)] +struct ProbeBody { + endpoint: String, + #[serde(default)] + genesis_hash: Option, + #[serde(default)] + follow: Option, + #[serde(default)] + require_peers: Option, + #[serde(default)] + require_synced: bool, +} + +#[tokio::main] +async fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + // rustls 0.23 requires a process-level crypto provider to be chosen before + // the first TLS connection, or it panics rather than returning an error. + if rustls::crypto::ring::default_provider() + .install_default() + .is_err() + { + log::error!("failed to install the rustls crypto provider"); + std::process::exit(1); + } + + // Render supplies PORT; default to something memorable for local runs. + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080); + + // Same-origin is the normal case, since this serves the page too. The + // allowance exists for the copy on GitHub Pages, which is a different + // origin and would otherwise be unable to reach a backend at all. + let cors = match std::env::var("ALLOWED_ORIGIN") { + Ok(origin) => match origin.parse::() { + Ok(value) => CorsLayer::new() + .allow_origin(value) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([axum::http::header::CONTENT_TYPE]), + Err(_) => { + log::error!("ALLOWED_ORIGIN is not a valid header value"); + std::process::exit(1); + } + }, + Err(_) => CorsLayer::new() + .allow_methods([Method::GET, Method::POST]) + .allow_headers([axum::http::header::CONTENT_TYPE]), + }; + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/probe", post(run_probe)) + // The page is served from here so that it and the API share an origin; + // that is what lets the page detect a backend and switch engines. + .fallback_service(ServeDir::new(web_root())) + .layer(cors) + .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BODY)) + .layer(TimeoutLayer::with_status_code( + StatusCode::GATEWAY_TIMEOUT, + REQUEST_DEADLINE, + )); + + let addr = SocketAddr::from((IpAddr::from([0, 0, 0, 0]), port)); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + log::error!("could not bind {addr}: {e}"); + std::process::exit(1); + } + }; + + info!("substrate-node-probe server listening on {addr}"); + if let Err(e) = axum::serve(listener, app).await { + log::error!("server stopped: {e}"); + std::process::exit(1); + } +} + +/// Where the static page lives. Overridable so the binary runs from a source +/// checkout as well as from the container, where the path differs. +fn web_root() -> String { + std::env::var("WEB_ROOT").unwrap_or_else(|_| "web".to_string()) +} + +/// Tells the page that a real backend is here, so it can offer to use it. +async fn health() -> impl IntoResponse { + Json(serde_json::json!({ + "ok": true, + "engine": "rust", + "version": env!("CARGO_PKG_VERSION"), + "max_follow": MAX_FOLLOW, + })) +} + +async fn run_probe(Json(body): Json) -> impl IntoResponse { + // Checked before anything is dialled. A rejection here is a refusal to act + // on the request at all, so it is a 400 rather than a probe report — the + // node was never contacted and has nothing to say. + if let Err(reason) = guard::public_websocket_endpoint(&body.endpoint).await { + warn!("refused endpoint {}: {reason}", body.endpoint); + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "ok": false, + "failure": "config", + "error": reason, + "endpoint": body.endpoint, + })), + ); + } + + let follow = body.follow.map(|n| n.min(MAX_FOLLOW)).filter(|n| *n > 0); + let req = ProbeRequest { + endpoint: body.endpoint, + genesis_hash: body.genesis_hash.filter(|h| !h.trim().is_empty()), + follow, + require_peers: body.require_peers, + require_synced: body.require_synced, + }; + + let timeouts = Timeouts { + connect: RPC_TIMEOUT, + rpc: RPC_TIMEOUT, + // Clamped well below the CLI's default: a caller willing to wait two + // minutes for one block can do that on their own machine. + head: MAX_HEAD_WAIT.min(SUBSCRIPTION_TIMEOUT), + }; + + info!("probing {}", req.endpoint); + let report: ProbeReport = probe_to_report(&req, timeouts).await; + + // 200 even when the probe failed: the request succeeded, and the report is + // the answer. HTTP status describes the conversation with this service, not + // the health of a third-party node. + let value = serde_json::to_value(&report).unwrap_or_else( + |e| serde_json::json!({ "ok": false, "failure": "protocol", "error": e.to_string() }), + ); + (StatusCode::OK, Json(value)) +} diff --git a/src/error.rs b/src/error.rs index 7542e49..276da69 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,7 +17,7 @@ use std::fmt; /// matching on error text that is free to be reworded at any time. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum Failure { +pub enum Failure { /// The command line asked for something impossible; the node is blameless. Config, /// The connection was never established. @@ -39,44 +39,44 @@ pub(crate) enum Failure { /// A failure, classified. #[derive(Debug)] -pub(crate) struct ProbeError { +pub struct ProbeError { kind: Failure, message: String, } impl ProbeError { - pub(crate) fn new(kind: Failure, message: impl Into) -> Self { + pub fn new(kind: Failure, message: impl Into) -> Self { ProbeError { kind, message: message.into(), } } - pub(crate) fn config(message: impl Into) -> Self { + pub fn config(message: impl Into) -> Self { Self::new(Failure::Config, message) } - pub(crate) fn connect(message: impl Into) -> Self { + pub fn connect(message: impl Into) -> Self { Self::new(Failure::Connect, message) } - pub(crate) fn timeout(message: impl Into) -> Self { + pub fn timeout(message: impl Into) -> Self { Self::new(Failure::Timeout, message) } - pub(crate) fn transport(message: impl Into) -> Self { + pub fn transport(message: impl Into) -> Self { Self::new(Failure::Transport, message) } - pub(crate) fn protocol(message: impl Into) -> Self { + pub fn protocol(message: impl Into) -> Self { Self::new(Failure::Protocol, message) } - pub(crate) fn rpc(message: impl Into) -> Self { + pub fn rpc(message: impl Into) -> Self { Self::new(Failure::RpcError, message) } - pub(crate) fn kind(&self) -> Failure { + pub fn kind(&self) -> Failure { self.kind } @@ -85,7 +85,7 @@ impl ProbeError { /// Used where an inner step already knows *what* went wrong and the outer /// step knows *how far it had got* — a timeout is still a timeout when you /// note it happened on the second of three headers. - pub(crate) fn context(mut self, extra: impl AsRef) -> Self { + pub fn context(mut self, extra: impl AsRef) -> Self { self.message = format!("{} {}", self.message, extra.as_ref()); self } diff --git a/src/guard.rs b/src/guard.rs new file mode 100644 index 0000000..8744570 --- /dev/null +++ b/src/guard.rs @@ -0,0 +1,217 @@ +//! Deciding whether a *stranger* may ask this process to dial a given address. +//! +//! This exists only for the hosted HTTP wrapper. Handing the public internet an +//! endpoint field that becomes an outbound connection is server-side request +//! forgery: without a check, anyone could point the service at `127.0.0.1`, at +//! a cloud metadata endpoint like `169.254.169.254`, or at another host inside +//! the provider's network, and read back whatever came out. +//! +//! **The CLI does not use any of this, and must not.** Probing `127.0.0.1:9944` +//! is the CLI's default and the entire point of running it against a local dev +//! node. The difference is who is asking: your own shell, or an anonymous POST. + +use std::net::IpAddr; +use url::Url; + +/// Why an endpoint was refused. The message is shown to the caller, so it says +/// what is allowed rather than describing the internal network. +pub type Rejection = String; + +/// Checks that `endpoint` is a WebSocket URL pointing somewhere on the public +/// internet, resolving the host to be sure. +/// +/// # Arguments +/// +/// * `endpoint` - The address the caller asked to probe. +/// +/// # Returns +/// +/// `Ok(())` if every address the host resolves to is public. +/// +/// # Limits +/// +/// Resolution here and the connection made later are separate lookups, so a +/// name whose record changes in between could still slip through (DNS +/// rebinding). Closing that needs the connection pinned to the address checked, +/// which the WebSocket client does not expose. This blocks the direct attack, +/// not a determined one — which is why the service also runs with no secrets, +/// no metadata credentials worth reaching, and short timeouts. +pub async fn public_websocket_endpoint(endpoint: &str) -> Result<(), Rejection> { + let url = Url::parse(endpoint).map_err(|e| format!("not a valid URL: {e}"))?; + + match url.scheme() { + "ws" | "wss" => {} + other => return Err(format!("scheme must be ws:// or wss://, got {other}://")), + } + + let port = url.port_or_known_default().unwrap_or(443); + + // Matched on the typed host rather than the string: `host_str()` hands back + // an IPv6 literal still wrapped in brackets (`[::1]`), which parses as + // neither an address nor a name, so a string-first version falls through to + // DNS and refuses `::1` only because the lookup happens to fail. That is + // the right answer for the wrong reason, and not a thing to rely on. + let host = match url.host() { + Some(url::Host::Ipv4(v4)) => { + let ip = IpAddr::V4(v4); + return if is_public(ip) { + Ok(()) + } else { + Err(reject(&ip.to_string())) + }; + } + Some(url::Host::Ipv6(v6)) => { + let ip = IpAddr::V6(v6); + return if is_public(ip) { + Ok(()) + } else { + Err(reject(&ip.to_string())) + }; + } + Some(url::Host::Domain(d)) => d.to_string(), + None => return Err("URL has no host".to_string()), + }; + + let resolved = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|e| format!("could not resolve {host}: {e}"))?; + + let mut any = false; + for addr in resolved { + any = true; + if !is_public(addr.ip()) { + return Err(reject(&host)); + } + } + + if any { + Ok(()) + } else { + Err(format!("{host} resolved to no addresses")) + } +} + +fn reject(host: &str) -> Rejection { + format!( + "{host} is not a public address. This hosted service only probes nodes reachable on the \ + public internet; to probe a private or local node, run the CLI, which has no such limit." + ) +} + +/// Whether an address is on the public internet. +/// +/// Written as an allowlist of "not one of these" rather than a range list so +/// that adding a case is a one-line change; the categories are the ones that +/// let an SSRF reach something interesting. +pub fn is_public(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let [a, b, ..] = v4.octets(); + !(v4.is_private() // 10/8, 172.16/12, 192.168/16 + || v4.is_loopback() // 127/8 + || v4.is_link_local() // 169.254/16 — cloud metadata lives here + || v4.is_broadcast() + || v4.is_documentation() + || v4.is_unspecified() // 0.0.0.0 + || a == 100 && (64..128).contains(&b) // 100.64/10 carrier-grade NAT + || a >= 224) // multicast and reserved + } + IpAddr::V6(v6) => { + // An IPv4-mapped address is an IPv4 address wearing a hat; judge the + // address underneath or ::ffff:127.0.0.1 walks straight through. + if let Some(v4) = v6.to_ipv4_mapped() { + return is_public(IpAddr::V4(v4)); + } + let seg = v6.segments(); + !(v6.is_loopback() + || v6.is_unspecified() + || (seg[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local + || (seg[0] & 0xffc0) == 0xfe80 // fe80::/10 link local + || (seg[0] & 0xff00) == 0xff00) // ff00::/8 multicast + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn public_addresses_are_allowed() { + for s in ["1.1.1.1", "8.8.8.8", "104.18.32.7", "2606:4700::1111"] { + assert!(is_public(ip(s)), "{s} should be public"); + } + } + + /// Each of these is a real place an SSRF wants to reach. + #[test] + fn private_and_special_addresses_are_refused() { + for s in [ + "127.0.0.1", // loopback + "0.0.0.0", // unspecified + "10.0.0.5", // private + "172.16.4.1", // private + "192.168.1.1", // private + "169.254.169.254", // cloud metadata + "100.64.0.1", // carrier-grade NAT + "224.0.0.1", // multicast + "::1", // v6 loopback + "fd00::1", // v6 unique local + "fe80::1", // v6 link local + "::ffff:127.0.0.1", // v4-mapped loopback — the sneaky one + "::ffff:169.254.169.254", // v4-mapped metadata + ] { + assert!(!is_public(ip(s)), "{s} should be refused"); + } + } + + /// 100.64/10 has public neighbours on both sides; check the edges rather + /// than assuming the range arithmetic is right. + #[test] + fn carrier_grade_nat_boundaries() { + assert!(is_public(ip("100.63.255.255")), "just below the range"); + assert!(!is_public(ip("100.64.0.0")), "first in range"); + assert!(!is_public(ip("100.127.255.255")), "last in range"); + assert!(is_public(ip("100.128.0.0")), "just above the range"); + } + + #[tokio::test] + async fn non_websocket_schemes_are_refused() { + for url in [ + "http://example.com", + "https://example.com", + "file:///etc/passwd", + "gopher://example.com", + ] { + assert!( + public_websocket_endpoint(url).await.is_err(), + "{url} should be refused" + ); + } + } + + #[tokio::test] + async fn loopback_literals_are_refused_without_dns() { + for url in [ + "ws://127.0.0.1:9944", + "ws://[::1]:9944", + "wss://169.254.169.254", + "ws://10.1.2.3:9944", + ] { + let err = public_websocket_endpoint(url) + .await + .expect_err("{url} should be refused"); + assert!(err.contains("not a public address"), "unhelpful: {err}"); + } + } + + #[tokio::test] + async fn garbage_is_refused_rather_than_dialled() { + assert!(public_websocket_endpoint("not a url").await.is_err()); + assert!(public_websocket_endpoint("ws://").await.is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c6d1215 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,184 @@ +//! Connects to a Substrate node, verifies which chain it serves, reports its +//! identity and health, and optionally follows new blocks. +//! +//! The probe itself lives here so that both front ends run *the same code*: the +//! `substrate-node-probe` CLI and the `serve` HTTP wrapper differ only in how +//! they collect a [`ProbeRequest`] and where they put the [`ProbeReport`]. +//! +//! The steps are in [`probe`], the connection in [`rpc`], the findings in +//! [`report`], and the failure taxonomy in [`error`]. + +pub mod error; +pub mod guard; +pub mod probe; +pub mod report; +pub mod rpc; +pub mod scale; +#[cfg(test)] +mod test_support; + +use log::info; + +use error::ProbeError; +use probe::{ + fetch_genesis_hash, follow_new_heads, parse_genesis_hash, query_node_info, verify_genesis_hash, +}; +use report::{check_requirements, ProbeReport}; +use rpc::Timeouts; + +/// What to probe, and what to demand of it. +/// +/// Deliberately not the clap struct: the CLI is one caller of this and the HTTP +/// wrapper is another, so the request has to be expressible without a command +/// line. +#[derive(Debug, Clone, Default)] +pub struct ProbeRequest { + /// The node's endpoint, `ws://` or `wss://`. + pub endpoint: String, + /// The genesis hash the node must report. `None` reports it without + /// enforcing anything. + pub genesis_hash: Option, + /// How many pushed headers to observe before unsubscribing. + pub follow: Option, + /// The minimum connected peers required. + pub require_peers: Option, + /// Whether a syncing node should be rejected. + pub require_synced: bool, +} + +/// Runs the probe, filling `report` as far as it gets. +/// +/// Each step writes what it learned into `report` before the next one runs, so +/// a failure part-way through still leaves everything gathered up to that point +/// — which is most of the value when the probe is watching a node that broke. +/// +/// # Arguments +/// +/// * `req` - What to probe and what to require of it. +/// * `timeouts` - The network waits to apply. +/// * `report` - Filled in as the run progresses. +/// +/// # Returns +/// +/// A Result indicating the success or failure of the run. +pub async fn probe( + req: &ProbeRequest, + timeouts: Timeouts, + report: &mut ProbeReport, +) -> Result<(), ProbeError> { + let expected_genesis = req + .genesis_hash + .as_deref() + .map(parse_genesis_hash) + .transpose() + .map_err(|e| ProbeError::config(format!("the required genesis hash is unusable: {e}")))?; + + let mut ws_stream = rpc::connect(&req.endpoint, timeouts.connect).await?; + + // Recorded before the comparison, so a mismatch is reported alongside the + // hash that caused it rather than only in the error text. + let genesis = fetch_genesis_hash(&mut ws_stream, timeouts).await?; + report.genesis_hash = Some(format!("0x{}", hex::encode(genesis.hash))); + report.rpc_latency_ms = Some(genesis.latency.as_millis()); + report.genesis_verified = verify_genesis_hash(genesis.hash, expected_genesis.as_ref())?; + + let info = query_node_info(&mut ws_stream, timeouts).await?; + report.name = info.name; + report.chain = info.chain; + report.version = info.version; + report.peers = info.peers; + report.is_syncing = info.is_syncing; + report.should_have_peers = info.should_have_peers; + report.best_block = info.best_block; + info!("Node information queried!"); + + // Judged before `--follow`, which can block for as long as the chain takes + // to produce a block: there is no sense watching heads on a node that has + // already failed the bar the caller set. + check_requirements(report, req.require_peers, req.require_synced)?; + + if let Some(count) = req.follow { + report.heads_followed = Some(follow_new_heads(&mut ws_stream, count, timeouts).await?); + } + + ws_stream.close(None).await.ok(); + Ok(()) +} + +/// Runs the probe and returns the report either way. +/// +/// The report is the deliverable whether or not the run succeeded — a probe +/// that yields nothing precisely when the node is broken is useless to whatever +/// is reading it — so this folds the outcome in rather than making every caller +/// remember to. +pub async fn probe_to_report(req: &ProbeRequest, timeouts: Timeouts) -> ProbeReport { + let mut report = ProbeReport { + endpoint: req.endpoint.clone(), + ..Default::default() + }; + + match probe(req, timeouts, &mut report).await { + Ok(()) => report.ok = true, + Err(e) => { + report.failure = Some(e.kind()); + report.error = Some(e.to_string()); + } + } + report +} + +#[cfg(test)] +mod tests { + use super::*; + + const POLKADOT_GENESIS: &str = + "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3"; + + /// The whole probe against the real Polkadot network — the one check the + /// mock node cannot make. The mock answers whatever the test file tells it + /// to, so it can only prove the client is self-consistent; it would keep + /// passing if a method were renamed, a result shape changed, or the `wss://` + /// and rustls path broke. Ignored by default because it needs the network + /// and depends on a third party being up: + /// + /// ```text + /// cargo test -- --ignored --nocapture + /// ``` + /// + /// Run on a schedule by `.github/workflows/live-probe.yml`, never on a PR. + #[tokio::test] + #[ignore = "hits the live Polkadot network; run with `cargo test -- --ignored`"] + async fn live_polkadot_endpoint_passes_every_check() { + // Another test in the process may have installed it already; only a + // genuine absence would matter, and that surfaces as a panic below. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let req = ProbeRequest { + endpoint: "wss://rpc.polkadot.io".to_string(), + genesis_hash: Some(POLKADOT_GENESIS.to_string()), + follow: Some(1), + require_peers: Some(1), + require_synced: true, + }; + + let report = probe_to_report(&req, Timeouts::default()).await; + + assert!(report.ok, "live probe failed: {report:#?}"); + assert!(report.genesis_verified, "genesis should have been enforced"); + assert_eq!(report.chain.as_deref(), Some("Polkadot")); + assert!(report.name.is_some(), "system_name went unanswered"); + assert!(report.version.is_some(), "system_version went unanswered"); + // The reason this test exists in its current form: system_health is the + // newest call here and the one most likely to drift. + assert!( + report.peers.unwrap_or(0) > 0, + "system_health reported no peers: {report:#?}" + ); + assert_eq!(report.is_syncing, Some(false)); + assert!( + report.best_block.unwrap_or(0) > 0, + "chain_getHeader gave no height: {report:#?}" + ); + assert_eq!(report.heads_followed, Some(1), "no header was pushed"); + } +} diff --git a/src/main.rs b/src/main.rs index 7f50647..5df991b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,29 +1,18 @@ -//! Connects to a Substrate node, verifies which chain it serves, reports its -//! identity and health, and optionally follows new blocks. +//! The command line: what to probe, what to demand of it, and how to report. //! -//! This file is the command line and the order the steps run in. The steps -//! themselves live in [`probe`], the connection in [`rpc`], and the findings in -//! [`report`]. - -mod error; -mod probe; -mod report; -mod rpc; -mod scale; -#[cfg(test)] -mod test_support; +//! The probe itself lives in the library so that this and the `serve` HTTP +//! wrapper run the same code — see [`substrate_node_probe`]. use clap::Parser; use env_logger::Env; -use log::{error, info}; +use log::error; use std::time::Duration; -use error::ProbeError; -use probe::{ - fetch_genesis_hash, follow_new_heads, parse_genesis_hash, query_node_info, verify_genesis_hash, +use substrate_node_probe::{ + probe_to_report, + rpc::{Timeouts, CONNECT_TIMEOUT, RPC_TIMEOUT, SUBSCRIPTION_TIMEOUT}, + ProbeRequest, }; -use report::{check_requirements, ProbeReport}; -use rpc::{Timeouts, CONNECT_TIMEOUT, RPC_TIMEOUT, SUBSCRIPTION_TIMEOUT}; /// Connect to a Substrate node, verify which chain it serves, and query its /// identity over JSON-RPC. @@ -87,61 +76,17 @@ impl Opt { head: Duration::from_secs(self.head_timeout), } } -} - -/// Connects to the node, verifies its chain identity and queries node info. -/// -/// Each step writes what it learned into `report` before the next one runs, so -/// a failure part-way through still leaves everything gathered up to that point -/// — which is most of the value when the probe is watching a node that broke. -/// -/// # Arguments -/// -/// * `opt` - The parsed command-line arguments. -/// * `timeouts` - The network waits to apply. -/// * `report` - Filled in as the run progresses. -/// -/// # Returns -/// -/// A Result indicating the success or failure of the run. -async fn run(opt: &Opt, timeouts: Timeouts, report: &mut ProbeReport) -> Result<(), ProbeError> { - let expected_genesis = opt - .genesis_hash - .as_deref() - .map(parse_genesis_hash) - .transpose() - .map_err(|e| ProbeError::config(format!("--genesis-hash is unusable: {e}")))?; - - let mut ws_stream = rpc::connect(&opt.node_address, timeouts.connect).await?; - - // Recorded before the comparison, so a mismatch is reported alongside the - // hash that caused it rather than only in the error text. - let genesis = fetch_genesis_hash(&mut ws_stream, timeouts).await?; - report.genesis_hash = Some(format!("0x{}", hex::encode(genesis.hash))); - report.rpc_latency_ms = Some(genesis.latency.as_millis()); - report.genesis_verified = verify_genesis_hash(genesis.hash, expected_genesis.as_ref())?; - - let info = query_node_info(&mut ws_stream, timeouts).await?; - report.name = info.name; - report.chain = info.chain; - report.version = info.version; - report.peers = info.peers; - report.is_syncing = info.is_syncing; - report.should_have_peers = info.should_have_peers; - report.best_block = info.best_block; - info!("Node information queried!"); - // Judged before `--follow`, which can block for as long as the chain takes - // to produce a block: there is no sense watching heads on a node that has - // already failed the bar the caller set. - check_requirements(report, opt.require_peers, opt.require_synced)?; - - if let Some(count) = opt.follow { - report.heads_followed = Some(follow_new_heads(&mut ws_stream, count, timeouts).await?); + /// What to probe, stripped of anything specific to a command line. + fn request(&self) -> ProbeRequest { + ProbeRequest { + endpoint: self.node_address.clone(), + genesis_hash: self.genesis_hash.clone(), + follow: self.follow, + require_peers: self.require_peers, + require_synced: self.require_synced, + } } - - ws_stream.close(None).await.ok(); - Ok(()) } /// The main function to run the program. @@ -160,19 +105,7 @@ async fn main() { } let opt = Opt::parse(); - let mut report = ProbeReport { - endpoint: opt.node_address.clone(), - ..Default::default() - }; - - let result = run(&opt, opt.timeouts(), &mut report).await; - match &result { - Ok(()) => report.ok = true, - Err(e) => { - report.failure = Some(e.kind()); - report.error = Some(e.to_string()); - } - } + let report = probe_to_report(&opt.request(), opt.timeouts()).await; // Printed on failure too: a probe that emits nothing precisely when the node // is broken is useless to whatever is parsing it. @@ -183,7 +116,7 @@ async fn main() { } } - if let Err(e) = result { + if let Some(e) = &report.error { error!("{e}"); std::process::exit(1); } @@ -193,69 +126,6 @@ async fn main() { mod tests { use super::*; - const POLKADOT_GENESIS: &str = - "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3"; - - /// The whole probe against the real Polkadot network — the one check the - /// mock node cannot make. The mock answers whatever the test file tells it - /// to, so it can only prove the client is self-consistent; it would keep - /// passing if a method were renamed, a result shape changed, or the `wss://` - /// and rustls path broke. Ignored by default because it needs the network - /// and depends on a third party being up: - /// - /// ```text - /// cargo test -- --ignored --nocapture - /// ``` - /// - /// Run on a schedule by `.github/workflows/live-probe.yml`, never on a PR. - #[tokio::test] - #[ignore = "hits the live Polkadot network; run with `cargo test -- --ignored`"] - async fn live_polkadot_endpoint_passes_every_check() { - // Another test in the process may have installed it already; only a - // genuine absence would matter, and that surfaces as a panic below. - let _ = rustls::crypto::ring::default_provider().install_default(); - - let opt = Opt { - node_address: "wss://rpc.polkadot.io".to_string(), - genesis_hash: Some(POLKADOT_GENESIS.to_string()), - follow: Some(1), - require_peers: Some(1), - require_synced: true, - json: false, - connect_timeout: CONNECT_TIMEOUT.as_secs(), - rpc_timeout: RPC_TIMEOUT.as_secs(), - head_timeout: SUBSCRIPTION_TIMEOUT.as_secs(), - }; - - let mut report = ProbeReport { - endpoint: opt.node_address.clone(), - ..Default::default() - }; - let result = run(&opt, opt.timeouts(), &mut report).await; - - assert!( - result.is_ok(), - "live probe failed: {:?}\nreport: {report:#?}", - result.err() - ); - assert!(report.genesis_verified, "genesis should have been enforced"); - assert_eq!(report.chain.as_deref(), Some("Polkadot")); - assert!(report.name.is_some(), "system_name went unanswered"); - assert!(report.version.is_some(), "system_version went unanswered"); - // The reason this test exists in its current form: system_health is the - // newest call here and the one most likely to drift. - assert!( - report.peers.unwrap_or(0) > 0, - "system_health reported no peers: {report:#?}" - ); - assert_eq!(report.is_syncing, Some(false)); - assert!( - report.best_block.unwrap_or(0) > 0, - "chain_getHeader gave no height: {report:#?}" - ); - assert_eq!(report.heads_followed, Some(1), "no header was pushed"); - } - /// The CLI is a contract too. A zero timeout would make every run fail /// instantly, which is never what someone means by asking for one. #[test] @@ -273,4 +143,13 @@ mod tests { "unset flags keep their defaults" ); } + + /// The default endpoint is a local dev node, which the hosted service would + /// refuse — the CLI deliberately has no such limit. + #[test] + fn defaults_target_a_local_dev_node() { + let opt = Opt::try_parse_from(["substrate-node-probe"]).unwrap(); + assert_eq!(opt.request().endpoint, "ws://127.0.0.1:9944"); + assert!(opt.request().genesis_hash.is_none(), "nothing enforced"); + } } diff --git a/src/probe.rs b/src/probe.rs index 3f4d2fd..acb0f06 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -19,26 +19,26 @@ use crate::rpc::{ /// What asking the node for block 0 produced. #[derive(Debug)] -pub(crate) struct GenesisInfo { +pub struct GenesisInfo { /// The hash the node reported. - pub(crate) hash: [u8; 32], + pub hash: [u8; 32], /// How long the node took to answer. - pub(crate) latency: Duration, + pub latency: Duration, } /// What the node said about itself. Every field is optional: a node may reject /// any single call — `system_health` in particular is not universally exposed — /// and one refused query should not sink the whole probe. #[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct NodeInfo { - pub(crate) name: Option, - pub(crate) chain: Option, - pub(crate) version: Option, - pub(crate) peers: Option, - pub(crate) is_syncing: Option, - pub(crate) should_have_peers: Option, +pub struct NodeInfo { + pub name: Option, + pub chain: Option, + pub version: Option, + pub peers: Option, + pub is_syncing: Option, + pub should_have_peers: Option, /// The number of the node's best block, from `chain_getHeader`. - pub(crate) best_block: Option, + pub best_block: Option, } /// Parses a hex-encoded 32-byte genesis hash. @@ -52,7 +52,7 @@ pub(crate) struct NodeInfo { /// The decoded hash, or a message explaining why the input was rejected. The /// caller classifies it, because the same bad value means different things /// coming from the command line and coming from the node. -pub(crate) fn parse_genesis_hash(hex_str: &str) -> Result<[u8; 32], String> { +pub fn parse_genesis_hash(hex_str: &str) -> Result<[u8; 32], String> { let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); let bytes = hex::decode(hex_str).map_err(|e| format!("genesis hash is not valid hex: {e}"))?; <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { @@ -85,7 +85,7 @@ fn parse_block_number(header: &serde_json::Value) -> Option { /// # Returns /// /// The hash the node reported and how long it took to answer. -pub(crate) async fn fetch_genesis_hash( +pub async fn fetch_genesis_hash( ws_stream: &mut NodeStream, timeouts: Timeouts, ) -> Result { @@ -149,7 +149,7 @@ pub(crate) async fn fetch_genesis_hash( /// /// Whether the hash was actually checked; `false` means no requirement was /// supplied and nothing has been proven. An error if the two differ. -pub(crate) fn verify_genesis_hash( +pub fn verify_genesis_hash( reported: [u8; 32], expected: Option<&[u8; 32]>, ) -> Result { @@ -191,7 +191,7 @@ pub(crate) fn verify_genesis_hash( /// What the node reported. A call the node refuses leaves its field empty /// rather than failing the probe; only a node that stops answering entirely is /// an error. -pub(crate) async fn query_node_info( +pub async fn query_node_info( ws_stream: &mut NodeStream, timeouts: Timeouts, ) -> Result { @@ -338,7 +338,7 @@ fn record_response(info: &mut NodeInfo, id: u64, result: &serde_json::Value) { /// /// How many headers were observed, or an error if the node refuses the /// subscription or stops producing blocks. -pub(crate) async fn follow_new_heads( +pub async fn follow_new_heads( ws_stream: &mut NodeStream, count: u64, timeouts: Timeouts, diff --git a/src/report.rs b/src/report.rs index 09a6529..de5131a 100644 --- a/src/report.rs +++ b/src/report.rs @@ -13,52 +13,52 @@ use crate::error::{Failure, ProbeError}; /// The report is filled in as the run progresses and is printed even when the /// run fails, because a monitor wants to know how far the probe got. #[derive(Debug, Default, Serialize)] -pub(crate) struct ProbeReport { +pub struct ProbeReport { /// The endpoint that was probed. - pub(crate) endpoint: String, + pub endpoint: String, /// Whether every step succeeded. `false` means `error` is set. - pub(crate) ok: bool, + pub ok: bool, /// What kind of thing went wrong, for a consumer that needs to branch. /// `error` below says the same thing in prose and is free to be reworded; /// this is the part that is safe to alert on. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) failure: Option, + pub failure: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) error: Option, + pub error: Option, /// The genesis hash the node reported, `0x`-prefixed. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) genesis_hash: Option, + pub genesis_hash: Option, /// True only when `--genesis-hash` was supplied *and* matched. Without the /// flag the hash above is reported but nothing is proven about it. - pub(crate) genesis_verified: bool, + pub genesis_verified: bool, /// Round-trip time of the `chain_getBlockHash` call — one honest latency /// sample, taken before the pipelined queries muddy the measurement. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) rpc_latency_ms: Option, + pub rpc_latency_ms: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) name: Option, + pub name: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) chain: Option, + pub chain: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) version: Option, + pub version: Option, /// Connected peers, from `system_health`. Zero on a live network means the /// node is isolated, which is the failure this probe exists to catch. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) peers: Option, + pub peers: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) is_syncing: Option, + pub is_syncing: Option, /// False for a dev chain running alone, which is why `peers: 0` is only /// alarming when this is true. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) should_have_peers: Option, + pub should_have_peers: Option, /// The node's best block number. Compare it across runs, or against another /// node, to tell a stuck node from a healthy one — the header carries no /// timestamp, so the probe cannot judge staleness on its own. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) best_block: Option, + pub best_block: Option, /// How many headers `--follow` observed. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) heads_followed: Option, + pub heads_followed: Option, } /// Decides whether the node meets the requirements the caller set. @@ -81,7 +81,7 @@ pub(crate) struct ProbeReport { /// # Returns /// /// A Result that is an error naming the first requirement the node failed. -pub(crate) fn check_requirements( +pub fn check_requirements( report: &ProbeReport, min_peers: Option, require_synced: bool, diff --git a/src/rpc.rs b/src/rpc.rs index d2bb564..c8ad008 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -14,42 +14,42 @@ use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; use crate::error::ProbeError; /// The client's end of the WebSocket connection to the node. -pub(crate) type NodeStream = WebSocketStream>; +pub type NodeStream = WebSocketStream>; /// JSON-RPC request ids, allocated here rather than per call site because they /// share one connection: responses are matched by id, so a subscription must /// not reuse the id of a query that may still be outstanding. -pub(crate) const ID_GENESIS: u64 = 0; -pub(crate) const ID_NAME: u64 = 1; -pub(crate) const ID_CHAIN: u64 = 2; -pub(crate) const ID_VERSION: u64 = 3; -pub(crate) const ID_HEALTH: u64 = 4; -pub(crate) const ID_SUBSCRIBE: u64 = 5; -pub(crate) const ID_UNSUBSCRIBE: u64 = 6; -pub(crate) const ID_HEADER: u64 = 7; +pub const ID_GENESIS: u64 = 0; +pub const ID_NAME: u64 = 1; +pub const ID_CHAIN: u64 = 2; +pub const ID_VERSION: u64 = 3; +pub const ID_HEALTH: u64 = 4; +pub const ID_SUBSCRIBE: u64 = 5; +pub const ID_UNSUBSCRIBE: u64 = 6; +pub const ID_HEADER: u64 = 7; /// Default wait for the WebSocket connection to be established. -pub(crate) const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Default wait for a node to answer a request. Without a bound, a node that /// accepts the socket and then goes quiet would hang the client. -pub(crate) const RPC_TIMEOUT: Duration = Duration::from_secs(10); +pub const RPC_TIMEOUT: Duration = Duration::from_secs(10); /// Default wait for a pushed block header. Deliberately far longer than /// `RPC_TIMEOUT`: this waits on the chain's block time, not on the node being /// responsive. Polkadot targets ~6s, but parachains and dev chains vary widely. -pub(crate) const SUBSCRIPTION_TIMEOUT: Duration = Duration::from_secs(120); +pub const SUBSCRIPTION_TIMEOUT: Duration = Duration::from_secs(120); /// The network waits, grouped so they can be shortened in tests and overridden /// from the command line. #[derive(Debug, Clone, Copy)] -pub(crate) struct Timeouts { +pub struct Timeouts { /// Waiting for the connection to be established. - pub(crate) connect: Duration, + pub connect: Duration, /// Waiting for the node to answer a request. - pub(crate) rpc: Duration, + pub rpc: Duration, /// Waiting for the chain to produce a block. - pub(crate) head: Duration, + pub head: Duration, } impl Default for Timeouts { @@ -70,7 +70,7 @@ impl Default for Timeouts { /// single wait short, the total unbounded. A deadline is set once when the /// operation starts and every read inside it draws down the same clock. #[derive(Debug, Clone, Copy)] -pub(crate) struct Deadline { +pub struct Deadline { at: Instant, /// The budget it was created with, kept only so timeout messages can name /// the limit the caller actually set. @@ -79,7 +79,7 @@ pub(crate) struct Deadline { impl Deadline { /// Starts a budget of `budget` from now. - pub(crate) fn after(budget: Duration) -> Self { + pub fn after(budget: Duration) -> Self { Deadline { at: Instant::now() + budget, budget, @@ -87,7 +87,7 @@ impl Deadline { } /// How much of the budget is left, or a timeout error if none is. - pub(crate) fn remaining(&self) -> Result { + pub fn remaining(&self) -> Result { self.at .checked_duration_since(Instant::now()) .filter(|left| !left.is_zero()) @@ -95,7 +95,7 @@ impl Deadline { } /// The error to report when this budget runs out. - pub(crate) fn expired(&self) -> ProbeError { + pub fn expired(&self) -> ProbeError { ProbeError::timeout(format!( "node sent no usable response within {:?}", self.budget @@ -113,7 +113,7 @@ impl Deadline { /// # Returns /// /// The open connection, or an error naming the address that failed. -pub(crate) async fn connect(address: &str, timeout: Duration) -> Result { +pub async fn connect(address: &str, timeout: Duration) -> Result { info!("Connecting to node at {address}"); let (ws_stream, response) = tokio::time::timeout(timeout, connect_async(address)) @@ -144,7 +144,7 @@ pub(crate) async fn connect(address: &str, timeout: Duration) -> Result Result { diff --git a/src/scale.rs b/src/scale.rs index 2d0d2ad..5067648 100644 --- a/src/scale.rs +++ b/src/scale.rs @@ -12,7 +12,7 @@ use parity_scale_codec::{Decode, Encode}; /// which requires Noise, multistream-select and yamux to reach. Chain identity /// is instead verified over RPC — see [`crate::probe::verify_genesis_hash`]. #[derive(Debug, PartialEq, Eq, Encode, Decode)] -pub(crate) struct HandshakeMessage { +pub struct HandshakeMessage { version: u32, name: String, chain: String, @@ -34,12 +34,7 @@ impl HandshakeMessage { /// /// A HandshakeMessage instance. #[allow(dead_code)] - pub(crate) fn new( - name: &str, - chain: &str, - genesis_hash: [u8; 32], - capabilities: Vec, - ) -> Self { + pub fn new(name: &str, chain: &str, genesis_hash: [u8; 32], capabilities: Vec) -> Self { HandshakeMessage { version: 1, name: name.to_string(), diff --git a/web/index.html b/web/index.html index 9262764..9cec43f 100644 --- a/web/index.html +++ b/web/index.html @@ -133,6 +133,18 @@ .elapsed { font-family: var(--mono); font-size: var(--step--1); color: var(--muted); font-variant-numeric: tabular-nums; margin-left: auto; } + /* Engine switch — only shown when a real backend answered /api/health. */ + .engine { display: inline-flex; align-items: center; gap: 0.6rem; } + .seg { display: inline-flex; border: 1px solid var(--rule-strong); border-radius: 2px; overflow: hidden; } + .seg button { + font-family: var(--mono); font-size: var(--step--1); font-weight: 600; + letter-spacing: 0.04em; text-transform: none; + padding: 0.3rem 0.6rem; border: 0; border-radius: 0; + background: transparent; color: var(--muted); + } + .seg button + button { border-left: 1px solid var(--rule-strong); } + .seg button.on { background: var(--accent); color: var(--ground); } + /* verdict banner */ .verdict-bar { margin-top: 1.5rem; border: 1px solid var(--rule-strong); border-radius: 3px; @@ -209,7 +221,7 @@

substrate-node-probe

Live · in your browser -

+

This is not the Rust binary. A browser cannot execute it. What runs here is a JavaScript reimplementation of the same checks, speaking the same JSON-RPC over the same WebSocket — same request ids, same failure kinds, same rule that an unprovable requirement @@ -259,6 +271,13 @@

substrate-node-probe

+
@@ -596,6 +615,119 @@

substrate-node-probe

const signal = { stopped: false }; let elapsedTimer = null; +/* Which implementation actually runs the checks. + "browser" — the JavaScript above, talking to the node directly. + "server" — POST to this origin, where the real Rust binary runs it. + Only offered when a backend answers; on a static host there is nothing to + switch to, and pretending otherwise would misrepresent what ran. */ +let engine = "browser"; +let backend = null; + +const DISCLOSURE = { + browser: `This is not the Rust binary. A browser cannot execute it. What runs + here is a JavaScript reimplementation of the same checks, speaking the same JSON-RPC over the + same WebSocket — same request ids, same failure kinds, same rule that an unprovable requirement + fails rather than passes. The connection is made directly from your browser to the node you + name; nothing is proxied and no data leaves your machine.`, + server: `This runs the real thing. Checks are executed server-side by the same + Rust binary you would run from a shell — not a reimplementation — and the report below is its + output, unmodified. The server dials the node on your behalf, so it will only probe endpoints + on the public internet; for a node on your own machine, use the CLI. Switch to + JavaScript to run the checks in this tab instead.`, +}; + +function setEngine(next) { + engine = next; + $("disclosure").innerHTML = DISCLOSURE[next]; + for (const b of document.querySelectorAll(".seg button")) { + b.classList.toggle("on", b.dataset.engine === next); + } + // The server caps how many headers it will sit and wait for. + if (next === "server" && backend?.max_follow !== undefined) { + const el = $("follow"); + if ((Number.parseInt(el.value, 10) || 0) > backend.max_follow) el.value = String(backend.max_follow); + } +} + +async function detectBackend() { + try { + const res = await fetch("api/health", { method: "GET" }); + if (!res.ok) return; + const body = await res.json(); + if (body?.engine !== "rust") return; + backend = body; + $("engine").hidden = false; + setEngine("server"); + } catch { + // No backend here — static hosting. Stay in browser mode silently. + } +} + +for (const b of document.querySelectorAll(".seg button")) { + b.addEventListener("click", () => { if (!running) setEngine(b.dataset.engine); }); +} + +/* Server mode returns one report at the end rather than streaming, so the step + rows are reconstructed from it. Derived from the report's own contents — the + step that failed is the one the failure kind names, and anything after it was + never reached. */ +function stepsFromReport(report, asked) { + const reached = (v) => v !== undefined && v !== null; + const failed = report.failure; + + const connect = failed === "connect" ? "fail" : "pass"; + const genesis = connect !== "pass" ? "skip" + : failed === "genesis_mismatch" ? "fail" + : reached(report.genesis_hash) ? "pass" : "fail"; + const query = genesis !== "pass" ? "skip" + : (reached(report.name) || reached(report.chain) || reached(report.peers) || reached(report.best_block)) ? "pass" + : "fail"; + const require = query !== "pass" ? "skip" : failed === "requirement_unmet" ? "fail" : "pass"; + const follow = require !== "pass" ? "skip" + : !asked.follow ? "off" + : reached(report.heads_followed) ? "pass" : "fail"; + + setStep("connect", connect, connect === "pass" ? "open" : "failed", + connect === "pass" ? "" : report.error ?? ""); + setStep("genesis", genesis === "skip" ? "skip" : genesis, + genesis === "pass" ? `${report.rpc_latency_ms ?? "?"} ms` : genesis === "fail" ? "failed" : "", + genesis === "pass" + ? (report.genesis_verified + ? `${shortHash((report.genesis_hash ?? "").replace(/^0x/, ""))} — matches the hash you required` + : `${shortHash((report.genesis_hash ?? "").replace(/^0x/, ""))} — reported only; nothing required, so nothing proven`) + : genesis === "fail" ? report.error ?? "" : "Not reached."); + setStep("query", query, + query === "pass" ? (reached(report.best_block) ? `#${report.best_block.toLocaleString()}` : "no height") : "", + query === "pass" + ? [report.name, report.chain, report.version && `v${report.version}`, + reached(report.peers) ? `${report.peers} peers` : "peer count not reported", + report.is_syncing === undefined ? "sync state not reported" : (report.is_syncing ? "syncing" : "synced"), + ].filter(Boolean).join(" · ") + : "Not reached."); + setStep("require", require, + require === "pass" ? (asked.bars.length ? "met" : "none set") : require === "fail" ? "failed" : "", + require === "pass" + ? (asked.bars.length ? `Required ${asked.bars.join(", ")}.` : "No bars set — the exit code only means the node answered.") + : require === "fail" ? report.error ?? "" : "Not reached."); + setStep("follow", follow === "off" ? "skip" : follow, + follow === "pass" ? `${report.heads_followed} / ${asked.follow}` : follow === "off" ? "off" : "", + follow === "pass" ? "Unsubscribed." + : follow === "off" ? "Set a count above to watch the chain produce blocks." + : follow === "fail" ? report.error ?? "" : "Not reached."); +} + +async function probeOnServer(body) { + const res = await fetch("api/probe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok && res.status !== 400) { + throw new ProbeError("transport", `the probe service answered ${res.status}`); + } + return res.json(); +} + $("preset").addEventListener("change", (e) => { const p = PRESETS[e.target.value]; if (!p) { $("endpoint").focus(); return; } @@ -648,6 +780,47 @@

substrate-node-probe

setVerdict("running", "Probing…", `${endpoint}`); + // Server mode: the real binary runs the checks and returns one report. + if (engine === "server") { + const bars = [ + minPeers !== null ? `peers ≥ ${minPeers}` : null, + requireSynced ? "not syncing" : null, + ].filter(Boolean); + + for (const [key] of STEPS) setStep(key, "running"); + + let served; + try { + served = await probeOnServer({ + endpoint, + genesis_hash: $("genesis").value.trim() || null, + follow: followCount || null, + require_peers: minPeers, + require_synced: requireSynced, + }); + } catch (err) { + served = { endpoint, ok: false, failure: err.kind ?? "transport", error: err.message }; + } + + stepsFromReport(served, { follow: followCount, bars }); + + if (served.ok) { + setVerdict("pass", "Usable", + `${served.chain ?? "This node"} · ${served.peers ?? "?"} peers · block ${served.best_block?.toLocaleString() ?? "?"} · ${served.rpc_latency_ms} ms round trip`); + } else { + setVerdict("fail", "Not usable", served.error ?? "the probe failed", served.failure); + } + + clearInterval(elapsedTimer); + $("elapsed").textContent = `${((performance.now() - started) / 1000).toFixed(1)}s`; + $("run").disabled = false; + $("stop").disabled = true; + running = false; + $("report-json").textContent = JSON.stringify(served, null, 2); + $("report").hidden = false; + return; + } + try { let expected = null; const genesisRaw = $("genesis").value.trim(); @@ -750,6 +923,7 @@

substrate-node-probe

} renderSteps(); +detectBackend();