From 913b56b0d0315d642e76393501a8fd0326eab9ed Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:41:06 +0200 Subject: [PATCH 01/24] feat: add tracing --- Cargo.lock | 480 +++++++++++++++++- Cargo.toml | 7 + crates/tracing/Cargo.toml | 25 + crates/tracing/examples/basic.rs | 76 +++ .../examples/test-infra/docker-compose.yml | 42 ++ .../examples/test-infra/prometheus.yml | 11 + .../examples/test-infra/promtail-config.yml | 18 + crates/tracing/src/config.rs | 185 +++++++ crates/tracing/src/init.rs | 70 +++ crates/tracing/src/layers/metrics.rs | 33 ++ crates/tracing/src/layers/mod.rs | 2 + crates/tracing/src/lib.rs | 19 + crates/tracing/src/metrics.rs | 15 + 13 files changed, 971 insertions(+), 12 deletions(-) create mode 100644 crates/tracing/Cargo.toml create mode 100644 crates/tracing/examples/basic.rs create mode 100644 crates/tracing/examples/test-infra/docker-compose.yml create mode 100644 crates/tracing/examples/test-infra/prometheus.yml create mode 100644 crates/tracing/examples/test-infra/promtail-config.yml create mode 100644 crates/tracing/src/config.rs create mode 100644 crates/tracing/src/init.rs create mode 100644 crates/tracing/src/layers/metrics.rs create mode 100644 crates/tracing/src/layers/mod.rs create mode 100644 crates/tracing/src/lib.rs create mode 100644 crates/tracing/src/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 795b1819..f853ade5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -450,8 +450,8 @@ name = "charon" version = "0.1.0" dependencies = [ "charon-build-proto", - "prost", - "prost-types", + "prost 0.14.1", + "prost-types 0.14.1", ] [[package]] @@ -481,9 +481,9 @@ dependencies = [ "hex", "k256", "libp2p", - "prost", + "prost 0.14.1", "prost-build", - "prost-types", + "prost-types 0.14.1", "rand_core 0.6.4", "serde", "serde_json", @@ -500,8 +500,8 @@ dependencies = [ "chrono", "hex", "libp2p", - "prost", - "prost-types", + "prost 0.14.1", + "prost-types 0.14.1", "serde", "serde_json", ] @@ -523,8 +523,8 @@ name = "charon-dkg" version = "0.1.0" dependencies = [ "charon-build-proto", - "prost", - "prost-types", + "prost 0.14.1", + "prost-types 0.14.1", ] [[package]] @@ -574,6 +574,19 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "charon-tracing" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-loki", + "tracing-subscriber", + "vise", + "vise-exporter", +] + [[package]] name = "chrono" version = "0.4.42" @@ -651,6 +664,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "compile-fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed69047ed42e52c7e38d6421eeb8ceefb4f2a2b52eed59137f7bad7908f6800" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -808,6 +827,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.111", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1050,6 +1079,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "elsa" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1145,6 +1183,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1601,12 +1654,29 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64", "bytes", "futures-channel", "futures-core", @@ -1614,7 +1684,9 @@ dependencies = [ "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2 0.6.1", "tokio", @@ -1858,6 +1930,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2665,6 +2747,16 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loki-api" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdc38a304f59a03e6efa3876766a48c70a766a93f88341c3fff4212834b8e327" +dependencies = [ + "prost 0.13.5", + "prost-types 0.13.5", +] + [[package]] name = "lru" version = "0.12.5" @@ -2691,6 +2783,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -2816,6 +2917,23 @@ dependencies = [ "unsigned-varint 0.7.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "netlink-packet-core" version = "0.7.0" @@ -2916,6 +3034,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2991,6 +3118,50 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "p256" version = "0.13.2" @@ -3115,6 +3286,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plotters" version = "0.3.7" @@ -3261,6 +3438,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.1" @@ -3268,7 +3455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.1", ] [[package]] @@ -3284,13 +3471,26 @@ dependencies = [ "once_cell", "petgraph", "prettyplease", - "prost", - "prost-types", + "prost 0.14.1", + "prost-types 0.14.1", "regex", "syn 2.0.111", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "prost-derive" version = "0.14.1" @@ -3304,13 +3504,22 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" dependencies = [ - "prost", + "prost 0.14.1", ] [[package]] @@ -3597,6 +3806,42 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -3758,6 +4003,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3802,6 +4056,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -3952,6 +4229,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3989,6 +4275,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "snow" version = "0.9.6" @@ -4102,6 +4394,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4208,6 +4503,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "threadpool" version = "1.8.1" @@ -4311,6 +4615,27 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -4340,6 +4665,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -4382,6 +4725,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-loki" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3beec919fbdf99d719de8eda6adae3281f8a5b71ae40431f44dc7423053d34" +dependencies = [ + "loki-api", + "reqwest", + "serde", + "serde_json", + "snap", + "tokio", + "tokio-stream", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", + "tracing-subscriber", + "url", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4472,12 +4876,64 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vise" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cec485349f926890c1d90a27e8f3147b552bf16cfd80212cd5ff72e8645056fd" +dependencies = [ + "compile-fmt", + "ctor", + "elsa", + "once_cell", + "prometheus-client", + "vise-macros", +] + +[[package]] +name = "vise-exporter" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f9be7083dca21a182f7ba0ec98f5e87dc9e47944f624443b4e7a6c9c911679" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "once_cell", + "tokio", + "tracing", + "vise", +] + +[[package]] +name = "vise-macros" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe466916e9bf50cc69aba41affc11970885e9dd2b9e448e20e1fb8fc726132ed" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 80db5052..461fb467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/charon-k1util", "crates/charon-p2p", "crates/charon-testutil", + "crates/tracing", ] resolver = "3" @@ -42,6 +43,11 @@ base64 = { version = "0.22.1" } sha3 = { version = "0.10.8" } k256 = { version = "0.13.4", features = ["ecdsa", "sha256"] } criterion = "0.7.0" +tracing = "0.1.32" +tracing-subscriber = { version = "0.3.9", features = ["env-filter"] } +tracing-loki = "0.2.6" +vise = "0.3.2" +vise-exporter = "0.3.2" # Crates in the workspace charon-build-proto = { path = "crates/charon-build-proto" } @@ -50,6 +56,7 @@ charon-eth2 = { path = "crates/charon-eth2" } charon-k1util = { path = "crates/charon-k1util" } charon-p2p = { path = "crates/charon-p2p" } charon-testutil = { path = "crates/charon-testutil" } +charon-tracing = { path = "crates/charon-tracing" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/tracing/Cargo.toml b/crates/tracing/Cargo.toml new file mode 100644 index 00000000..2f43f090 --- /dev/null +++ b/crates/tracing/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "charon-tracing" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +tracing.workspace = true +tracing-subscriber.workspace = true +tracing-loki.workspace = true +thiserror.workspace = true +tokio.workspace = true +vise.workspace = true + +[[example]] +name = "basic" + +[dev-dependencies] +vise-exporter.workspace = true +tokio = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/crates/tracing/examples/basic.rs b/crates/tracing/examples/basic.rs new file mode 100644 index 00000000..19e4ee5d --- /dev/null +++ b/crates/tracing/examples/basic.rs @@ -0,0 +1,76 @@ +//! Basic example demonstrating the charon-tracing functionality. +//! +//! Run the following command to start the test infrastructure: +//! ```bash +//! docker compose -f test-infra/docker-compose.yml up -d +//! ``` +//! +//! Run the following command to start the example: +//! ```bash +//! cargo run --example basic +//! ``` +//! +//! You can see the logs in Grafana at http://localhost:3000. +use std::{collections::HashMap, net::SocketAddr}; + +use charon_tracing::{LokiConfig, config::TracingConfig, init::init}; +use tracing::{debug, error, info, instrument, trace, warn}; +use vise_exporter::MetricsExporter; + +#[tokio::main] +async fn main() { + // Initialize tracing with default console config + let config = TracingConfig::builder() + .with_default_console() + .with_metrics(true) + .loki(LokiConfig { + loki_url: "http://localhost:3100".to_string(), + labels: HashMap::new(), + extra_fields: HashMap::new(), + }) + .override_env_filter("debug") + .build(); + + let background_task = init(&config) + .expect("Failed to initialize tracing") + .unwrap(); + + tokio::spawn(background_task); + + let bind_address = SocketAddr::from(([0, 0, 0, 0], 9464)); + + let exporter = MetricsExporter::default().bind(bind_address).await.unwrap(); + tokio::spawn(async move { + exporter.start().await.unwrap(); + }); + + // Test various log levels + trace!("This is a trace message"); + debug!("This is a debug message"); + info!("This is an info message"); + warn!("This is a warning message"); + error!("This is an error message"); + + // Test structured logging with fields + info!(user_id = 42, action = "login", "User performed an action"); + + instrumented_function(); + + // Test spans + let span = tracing::info_span!("processing", request_id = "abc-123"); + let _guard = span.enter(); + + info!("Processing started"); + debug!("Debug info inside span"); + info!("Processing completed"); + + // Wait for 10 seconds to see the logs in Loki + std::thread::sleep(std::time::Duration::from_secs(10)); +} + +#[instrument] +fn instrumented_function() { + info!("Instrumented function started"); + debug!("Debug info inside instrumented function"); + info!("Instrumented function completed"); +} diff --git a/crates/tracing/examples/test-infra/docker-compose.yml b/crates/tracing/examples/test-infra/docker-compose.yml new file mode 100644 index 00000000..545f0f56 --- /dev/null +++ b/crates/tracing/examples/test-infra/docker-compose.yml @@ -0,0 +1,42 @@ +version: "3" + +services: + loki: + image: grafana/loki:2.9.0 + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + volumes: + - loki-data:/loki + + promtail: + image: grafana/promtail:2.9.0 + volumes: + - /var/log:/var/log + - ./promtail-config.yml:/etc/promtail/config.yml + command: -config.file=/etc/promtail/config.yml + + prometheus: + image: prom/prometheus:latest + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + extra_hosts: + - "host.docker.internal:host-gateway" # Allows access to host machine + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-data:/var/lib/grafana + +volumes: + loki-data: + grafana-data: + prometheus-data: \ No newline at end of file diff --git a/crates/tracing/examples/test-infra/prometheus.yml b/crates/tracing/examples/test-infra/prometheus.yml new file mode 100644 index 00000000..0eefb9b7 --- /dev/null +++ b/crates/tracing/examples/test-infra/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'local-exporter' + static_configs: + - targets: ['host.docker.internal:9464'] + labels: + instance: 'local-exporter' + environment: 'dev' \ No newline at end of file diff --git a/crates/tracing/examples/test-infra/promtail-config.yml b/crates/tracing/examples/test-infra/promtail-config.yml new file mode 100644 index 00000000..0fceee85 --- /dev/null +++ b/crates/tracing/examples/test-infra/promtail-config.yml @@ -0,0 +1,18 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: system + static_configs: + - targets: + - localhost + labels: + job: varlogs + __path__: /var/log/*log \ No newline at end of file diff --git a/crates/tracing/src/config.rs b/crates/tracing/src/config.rs new file mode 100644 index 00000000..5453bcbe --- /dev/null +++ b/crates/tracing/src/config.rs @@ -0,0 +1,185 @@ +use std::collections::HashMap; + +/// Configuration for the tracing. +#[derive(Debug, Clone, Default)] +pub struct TracingConfig { + /// Loki configuration. Enables loki logging if provided. If not - no loki + /// logging is enabled. + pub loki: Option, + + /// Console configuration. Enables console logging if provided. If not - no + /// console logging is enabled. + pub console: Option, + + /// Enables metrics logging. If not - no metrics logging is enabled. + pub metrics: bool, + + /// Overrides the environment filter. If not - the environment filter is + /// used. + pub override_env_filter: Option, +} + +/// Configuration for the loki logging. +#[derive(Debug, Clone)] +pub struct LokiConfig { + /// URL of the Loki instance. + pub loki_url: String, + + /// Labels to add to the Loki logs. + pub labels: HashMap, + + /// Extra fields to add to the Loki logs. + pub extra_fields: HashMap, +} + +/// Configuration for the console logging. +#[derive(Debug, Clone)] +pub struct ConsoleConfig { + /// Whether to include the target module in logs. + pub with_target: bool, + + /// Whether to include the log level in logs. + pub with_level: bool, + + /// Whether to include thread IDs in logs. + pub with_thread_ids: bool, + + /// Whether to include the source file name in logs. + pub with_file: bool, + + /// Whether to include line numbers in logs. + pub with_line_number: bool, + + /// Whether to use ANSI colors in logs. + pub with_ansi: bool, +} + +impl Default for ConsoleConfig { + fn default() -> Self { + Self { + with_target: true, + with_level: true, + with_thread_ids: false, + with_file: false, + with_line_number: false, + with_ansi: true, + } + } +} + +/// Builder for [`TracingConfig`]. +#[derive(Debug, Clone, Default)] +pub struct TracingConfigBuilder { + tracing_config: TracingConfig, +} + +impl TracingConfigBuilder { + /// Creates a new builder with default values. + pub fn new() -> Self { + Self::default() + } + + /// Sets the Loki configuration. + pub fn loki(mut self, config: LokiConfig) -> Self { + self.tracing_config.loki = Some(config); + self + } + + /// Sets the console configuration. + pub fn console(mut self, config: ConsoleConfig) -> Self { + self.tracing_config.console = Some(config); + self + } + + /// Enables console logging with default configuration. + pub fn with_default_console(mut self) -> Self { + self.tracing_config.console = Some(ConsoleConfig::default()); + self + } + + /// Enables console logging and configures whether to include the target + /// module. + pub fn console_with_target(mut self, with_target: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_target = with_target; + self + } + + /// Enables console logging and configures whether to include the log level. + pub fn console_with_level(mut self, with_level: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_level = with_level; + self + } + + /// Enables console logging and configures whether to include thread IDs. + pub fn console_with_thread_ids(mut self, with_thread_ids: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_thread_ids = with_thread_ids; + self + } + + /// Enables console logging and configures whether to include the source + /// file name. + pub fn console_with_file(mut self, with_file: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_file = with_file; + self + } + + /// Enables console logging and configures whether to include line numbers. + pub fn console_with_line_number(mut self, with_line_number: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_line_number = with_line_number; + self + } + + /// Enables console logging and configures whether to use ANSI colors. + pub fn console_with_ansi(mut self, with_ansi: bool) -> Self { + self.tracing_config + .console + .get_or_insert_with(ConsoleConfig::default) + .with_ansi = with_ansi; + self + } + + /// Enables metrics logging. + pub fn with_metrics(mut self, enabled: bool) -> Self { + self.tracing_config.metrics = enabled; + self + } + + /// Sets whether metrics logging is enabled. + pub fn metrics(mut self, enabled: bool) -> Self { + self.tracing_config.metrics = enabled; + self + } + + /// Sets the environment filter override. + pub fn override_env_filter(mut self, filter: impl Into) -> Self { + self.tracing_config.override_env_filter = Some(filter.into()); + self + } + + /// Builds the [`TracingConfig`]. + pub fn build(self) -> TracingConfig { + self.tracing_config + } +} + +impl TracingConfig { + /// Creates a new builder for [`TracingConfig`]. + pub fn builder() -> TracingConfigBuilder { + TracingConfigBuilder::new() + } +} diff --git a/crates/tracing/src/init.rs b/crates/tracing/src/init.rs new file mode 100644 index 00000000..cec0c373 --- /dev/null +++ b/crates/tracing/src/init.rs @@ -0,0 +1,70 @@ +use std::str::FromStr; + +use tracing_loki::{BackgroundTask, url::Url}; +use tracing_subscriber::{ + EnvFilter, Registry, layer::SubscriberExt as _, util::SubscriberInitExt as _, +}; + +use crate::{config::TracingConfig, layers::metrics::MetricsLayer}; + +/// Error type for tracing initialization errors. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to initialize tracing subscriber. + #[error("failed to initialize tracing subscriber: {0}")] + InitError(#[from] tracing_subscriber::util::TryInitError), + + /// Failed to parse Loki URL. + #[error("failed to parse Loki URL: {0}")] + ParseError(#[from] tracing_loki::url::ParseError), + + /// Failed to create Loki layer. + #[error("failed to create Loki layer: {0}")] + CreateLayerError(#[from] tracing_loki::Error), +} + +type Result = std::result::Result; + +/// Initializes the tracing subscriber. +pub fn init(config: &TracingConfig) -> Result> { + let env_filter = if let Some(override_env_filter) = config.override_env_filter.as_ref() { + EnvFilter::from_str(override_env_filter).unwrap_or_else(|_| default_env_filter()) + } else { + EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| default_env_filter()) + }; + + let console_config = config.console.clone().unwrap_or_default(); + + let fmt_layer = tracing_subscriber::fmt::layer() + .with_target(console_config.with_target) + .with_level(console_config.with_level) + .with_thread_ids(console_config.with_thread_ids) + .with_file(console_config.with_file) + .with_line_number(console_config.with_line_number) + .with_ansi(console_config.with_ansi); + + let registry = Registry::default() + .with(env_filter) + .with(fmt_layer) + .with(MetricsLayer); + + if let Some(loki_config) = &config.loki { + let (loki_layer, background_worker) = tracing_loki::layer( + Url::parse(&loki_config.loki_url)?, + loki_config.labels.clone(), + loki_config.extra_fields.clone(), + )?; + + let registry = registry.with(loki_layer); + registry.try_init()?; + + Ok(Some(background_worker)) + } else { + registry.try_init()?; + Ok(None) + } +} + +fn default_env_filter() -> EnvFilter { + EnvFilter::new("info") +} diff --git a/crates/tracing/src/layers/metrics.rs b/crates/tracing/src/layers/metrics.rs new file mode 100644 index 00000000..390552a8 --- /dev/null +++ b/crates/tracing/src/layers/metrics.rs @@ -0,0 +1,33 @@ +use crate::metrics::TRACING_METRICS; + +/// Metrics layer. +pub struct MetricsLayer; + +fn inc_error_count() { + TRACING_METRICS.error_count.inc(); +} + +fn inc_warn_count() { + TRACING_METRICS.warn_count.inc(); +} + +impl tracing_subscriber::Layer for MetricsLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + // check level + match event.metadata().level() { + &tracing::Level::ERROR => { + inc_error_count(); + } + &tracing::Level::WARN => { + inc_warn_count(); + } + _ => { + // do nothing + } + } + } +} diff --git a/crates/tracing/src/layers/mod.rs b/crates/tracing/src/layers/mod.rs new file mode 100644 index 00000000..2b0baf8a --- /dev/null +++ b/crates/tracing/src/layers/mod.rs @@ -0,0 +1,2 @@ +/// Metrics layer. +pub mod metrics; diff --git a/crates/tracing/src/lib.rs b/crates/tracing/src/lib.rs new file mode 100644 index 00000000..b9c93282 --- /dev/null +++ b/crates/tracing/src/lib.rs @@ -0,0 +1,19 @@ +//! # Charon Observability +//! +//! Observability and monitoring utilities for the Charon distributed validator +//! node. This crate provides logging, metrics, tracing, and monitoring +//! capabilities for tracking and debugging validator operations. + +/// Configuration for the tracing. +pub mod config; + +/// Initialization for the tracing. +pub mod init; + +/// Layers for the tracing. +pub mod layers; + +/// Metrics for the tracing. +pub mod metrics; + +pub use config::{ConsoleConfig, LokiConfig, TracingConfig, TracingConfigBuilder}; diff --git a/crates/tracing/src/metrics.rs b/crates/tracing/src/metrics.rs new file mode 100644 index 00000000..2374fd7b --- /dev/null +++ b/crates/tracing/src/metrics.rs @@ -0,0 +1,15 @@ +use vise::{Counter, Metrics}; + +/// Metrics for the tracing. +#[derive(Debug, Metrics)] +pub struct TracingMetrics { + /// Error count. + pub error_count: Counter, + + /// Warn count. + pub warn_count: Counter, +} + +/// Global metrics for the tracing. +#[vise::register] +pub static TRACING_METRICS: vise::Global = vise::Global::new(); From 51a691f4e4596a0b66d5913e454e105c22d0a0ad Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:45:18 +0200 Subject: [PATCH 02/24] fix: linting, cargo-deny --- Cargo.toml | 1 - crates/tracing/examples/basic.rs | 12 +++++++++--- crates/tracing/src/layers/metrics.rs | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 461fb467..eafea64e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,6 @@ charon-eth2 = { path = "crates/charon-eth2" } charon-k1util = { path = "crates/charon-k1util" } charon-p2p = { path = "crates/charon-p2p" } charon-testutil = { path = "crates/charon-testutil" } -charon-tracing = { path = "crates/charon-tracing" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/tracing/examples/basic.rs b/crates/tracing/examples/basic.rs index 19e4ee5d..38898302 100644 --- a/crates/tracing/examples/basic.rs +++ b/crates/tracing/examples/basic.rs @@ -33,15 +33,21 @@ async fn main() { let background_task = init(&config) .expect("Failed to initialize tracing") - .unwrap(); + .expect("Background task should be Some"); tokio::spawn(background_task); let bind_address = SocketAddr::from(([0, 0, 0, 0], 9464)); - let exporter = MetricsExporter::default().bind(bind_address).await.unwrap(); + let exporter = MetricsExporter::default() + .bind(bind_address) + .await + .expect("Failed to bind metrics exporter"); tokio::spawn(async move { - exporter.start().await.unwrap(); + exporter + .start() + .await + .expect("Failed to start metrics exporter"); }); // Test various log levels diff --git a/crates/tracing/src/layers/metrics.rs b/crates/tracing/src/layers/metrics.rs index 390552a8..d7c0cb04 100644 --- a/crates/tracing/src/layers/metrics.rs +++ b/crates/tracing/src/layers/metrics.rs @@ -18,11 +18,11 @@ impl tracing_subscriber::Layer for MetricsLayer { _ctx: tracing_subscriber::layer::Context<'_, S>, ) { // check level - match event.metadata().level() { - &tracing::Level::ERROR => { + match *event.metadata().level() { + tracing::Level::ERROR => { inc_error_count(); } - &tracing::Level::WARN => { + tracing::Level::WARN => { inc_warn_count(); } _ => { From 85a9b9c2a85bcb7e8e8f7cf278ee95bf5520778f Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 8 Dec 2025 14:15:34 +0200 Subject: [PATCH 03/24] feat: add p2p config --- crates/charon-p2p/src/config.rs | 237 ++++++++++++++++++++++++++++++++ crates/charon-p2p/src/lib.rs | 3 + 2 files changed, 240 insertions(+) create mode 100644 crates/charon-p2p/src/config.rs diff --git a/crates/charon-p2p/src/config.rs b/crates/charon-p2p/src/config.rs new file mode 100644 index 00000000..902a5998 --- /dev/null +++ b/crates/charon-p2p/src/config.rs @@ -0,0 +1,237 @@ +//! # Charon P2P Configuration + +use std::{ + net::{IpAddr, SocketAddr}, + str::FromStr, +}; + +use libp2p::{Multiaddr, multiaddr}; + +/// P2P configuration error. +#[derive(Debug, thiserror::Error)] +pub enum P2PConfigError { + /// Failed to parse the TCP addresses. + #[error("Failed to parse the TCP addresses")] + FailedToParseTcpAddresses(std::net::AddrParseError), + + /// Failed to parse the UDP addresses. + #[error("Failed to parse the UDP addresses")] + FailedToParseUdpAddresses(std::net::AddrParseError), + + /// Failed to parse the multiaddress. + #[error("Failed to parse the multiaddress")] + FailedToParseMultiaddr(#[from] multiaddr::Error), + + /// Unspecified IP address. + #[error("Unspecified IP address: {0}")] + UnspecifiedIP(String), +} + +// Note: this is only for testing purposes! +#[cfg(test)] +impl PartialEq for P2PConfigError { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + ( + P2PConfigError::FailedToParseTcpAddresses(x), + P2PConfigError::FailedToParseTcpAddresses(y), + ) if x == y => true, + ( + P2PConfigError::FailedToParseUdpAddresses(x), + P2PConfigError::FailedToParseUdpAddresses(y), + ) if x == y => true, + ( + P2PConfigError::FailedToParseMultiaddr(x), + P2PConfigError::FailedToParseMultiaddr(y), + ) if x.to_string() == y.to_string() => true, + (P2PConfigError::UnspecifiedIP(x), P2PConfigError::UnspecifiedIP(y)) if x == y => true, + _ => false, + } + } +} + +type Result = std::result::Result; + +/// P2P configuration. +#[derive(Debug, Clone, Default)] +pub struct P2PConfig { + /// Defines the libp2p relay multiaddrs or URLs. + pub relays: Vec, + + /// The external IP address of the node. + pub external_ip: String, + + /// The external host of the node. + pub external_host: String, + + /// The TCP addresses of the node. + pub tcp_addrs: Vec, + + /// The UDP addresses of the node. + pub udp_addrs: Vec, + + /// Whether to disable the reuse port. + pub disable_reuse_port: bool, +} + +impl P2PConfig { + /// Returns the TCP addresses of the node. + pub fn parse_tcp_addrs(&self) -> Result> { + self.tcp_addrs.iter().map(resolve_listen_tcp_addr).collect() + } + + /// Returns the UDP addresses of the node. + pub fn parse_udp_addrs(&self) -> Result> { + self.udp_addrs.iter().map(resolve_listen_udp_addr).collect() + } + + /// Returns the UDP multiaddresses of the node. + pub fn udp_multiaddrs(&self) -> Result> { + let addrs = self.parse_udp_addrs()?; + + addrs.into_iter().map(multi_addr_from_ip_udp_port).collect() + } + + /// Returns the TCP multiaddresses of the node. + pub fn tcp_multiaddrs(&self) -> Result> { + let addrs = self.parse_tcp_addrs()?; + + addrs.into_iter().map(multi_addr_from_ip_tcp_port).collect() + } +} + +/// Resolves a TCP address string to a SocketAddr, ensuring the IP is specified. +fn resolve_listen_tcp_addr(addr: impl AsRef) -> Result { + let socket_addr: SocketAddr = addr + .as_ref() + .parse() + .map_err(P2PConfigError::FailedToParseTcpAddresses)?; + + // Go version checks if IP is nil (unspecified) + if socket_addr.ip().is_unspecified() { + return Err(P2PConfigError::UnspecifiedIP("TCP".to_string())); + } + + Ok(socket_addr) +} + +/// Resolves a UDP address string to a SocketAddr, ensuring the IP is specified. +fn resolve_listen_udp_addr(addr: impl AsRef) -> Result { + let socket_addr: SocketAddr = addr + .as_ref() + .parse() + .map_err(P2PConfigError::FailedToParseUdpAddresses)?; + + if socket_addr.ip().is_unspecified() { + return Err(P2PConfigError::UnspecifiedIP("UDP".to_string())); + } + + Ok(socket_addr) +} + +fn multi_addr_from_ip_udp_port(socket_addr: SocketAddr) -> Result { + let typ = match socket_addr.ip() { + IpAddr::V4(_) => "ip4", + IpAddr::V6(_) => "ip6", + }; + + Multiaddr::from_str(&format!( + "/{}/{}/udp/{}/quic-v1", + typ, + socket_addr.ip(), + socket_addr.port() + )) + .map_err(P2PConfigError::FailedToParseMultiaddr) +} + +fn multi_addr_from_ip_tcp_port(socket_addr: SocketAddr) -> Result { + let typ = match socket_addr.ip() { + IpAddr::V4(_) => "ip4", + IpAddr::V6(_) => "ip6", + }; + + Multiaddr::from_str(&format!( + "/{}/{}/tcp/{}", + typ, + socket_addr.ip(), + socket_addr.port() + )) + .map_err(P2PConfigError::FailedToParseMultiaddr) +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use super::*; + + #[test] + fn test_resolve_listen_addr_ip_not_specified() { + let err = resolve_listen_tcp_addr(":1234").unwrap_err(); + assert!(matches!(err, P2PConfigError::FailedToParseTcpAddresses(_))); + } + + #[test] + fn test_resolve_listen_addr_ip() { + let addr = resolve_listen_tcp_addr("10.4.3.3:1234").unwrap(); + assert_eq!( + addr, + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 4, 3, 3)), 1234) + ); + } + + #[test] + fn test_config_multiaddrs() { + // IPv6linklocalallnodes = IP{0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + // 0, 0, 0x01} + let ipv6_linklocal_all_nodes = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1); + + let config = P2PConfig { + tcp_addrs: vec![ + "10.0.0.2:0".to_string(), + format!("[{}]:0", ipv6_linklocal_all_nodes), + ], + udp_addrs: vec![ + "10.0.0.2:0".to_string(), + format!("[{}]:0", ipv6_linklocal_all_nodes), + ], + ..Default::default() + }; + + let tcp_multiaddrs = config.tcp_multiaddrs().unwrap(); + let udp_multiaddrs = config.udp_multiaddrs().unwrap(); + + let tcp_addrs_str = tcp_multiaddrs + .iter() + .map(|addr| addr.to_string()) + .collect::>(); + let udp_addrs_str = udp_multiaddrs + .iter() + .map(|addr| addr.to_string()) + .collect::>(); + + let merged_addrs_str = tcp_addrs_str + .into_iter() + .chain(udp_addrs_str) + .collect::>(); + + let expected_addrs_str = vec![ + "/ip4/10.0.0.2/tcp/0", + "/ip6/ff02::1/tcp/0", + "/ip4/10.0.0.2/udp/0/quic-v1", + "/ip6/ff02::1/udp/0/quic-v1", + ]; + + assert_eq!(merged_addrs_str, expected_addrs_str); + } + + #[test] + fn test_config_invalid_multiaddrs() { + let config = P2PConfig { + tcp_addrs: vec!["not_a_valid_addr".to_string()], + ..Default::default() + }; + + assert!(config.tcp_multiaddrs().is_err()); + } +} diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index b2ba356d..63312162 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -10,3 +10,6 @@ pub mod peer; /// Name-related types and utilities. pub mod name; + +/// P2P configuration. +pub mod config; From 65d5ae73eebf2bebf509becae38af5df91aaa11a Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 9 Dec 2025 10:30:21 +0200 Subject: [PATCH 04/24] fix: remove comment --- crates/charon-p2p/src/config.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/charon-p2p/src/config.rs b/crates/charon-p2p/src/config.rs index 902a5998..ffe15a1b 100644 --- a/crates/charon-p2p/src/config.rs +++ b/crates/charon-p2p/src/config.rs @@ -182,8 +182,6 @@ mod tests { #[test] fn test_config_multiaddrs() { - // IPv6linklocalallnodes = IP{0xff, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - // 0, 0, 0x01} let ipv6_linklocal_all_nodes = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1); let config = P2PConfig { From 1cc2a29ca65e24783d26836b7ffdabcff541a928 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:27:59 +0200 Subject: [PATCH 05/24] feat: add p2p metrics --- Cargo.lock | 3 + crates/charon-p2p/Cargo.toml | 3 + crates/charon-p2p/examples/metrics.rs | 59 +++++++ crates/charon-p2p/src/lib.rs | 3 + crates/charon-p2p/src/metrics.rs | 149 ++++++++++++++++++ .../docker-compose.yml | 0 .../test-infra => test-infra}/prometheus.yml | 0 .../promtail-config.yml | 0 8 files changed, 217 insertions(+) create mode 100644 crates/charon-p2p/examples/metrics.rs create mode 100644 crates/charon-p2p/src/metrics.rs rename {crates/tracing/examples/test-infra => test-infra}/docker-compose.yml (100%) rename {crates/tracing/examples/test-infra => test-infra}/prometheus.yml (100%) rename {crates/tracing/examples/test-infra => test-infra}/promtail-config.yml (100%) diff --git a/Cargo.lock b/Cargo.lock index f853ade5..37f20a2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,9 @@ dependencies = [ "k256", "libp2p", "thiserror 2.0.17", + "tokio", + "vise", + "vise-exporter", ] [[package]] diff --git a/crates/charon-p2p/Cargo.toml b/crates/charon-p2p/Cargo.toml index c140c7ec..274057bb 100644 --- a/crates/charon-p2p/Cargo.toml +++ b/crates/charon-p2p/Cargo.toml @@ -12,9 +12,12 @@ thiserror.workspace = true k256.workspace = true charon-eth2.workspace = true charon-k1util.workspace = true +vise.workspace = true +tokio.workspace = true [dev-dependencies] charon-testutil.workspace = true +vise-exporter.workspace = true [lints] workspace = true diff --git a/crates/charon-p2p/examples/metrics.rs b/crates/charon-p2p/examples/metrics.rs new file mode 100644 index 00000000..e9ccbb50 --- /dev/null +++ b/crates/charon-p2p/examples/metrics.rs @@ -0,0 +1,59 @@ +//! Example demonstrating the charon-p2p metrics functionality. +//! +//! To run this example, run the local Prometheus and Grafana containers: +//! ```bash +//! docker compose -f test-infra/docker-compose.yml up -d +//! ``` +//! +//! Then run the example: +//! ```bash +//! cargo run --example metrics -p charon-p2p +//! ``` +//! +//! Metrics will be available in Grafana at http://localhost:3000. + +use std::net::SocketAddr; + +use charon_p2p::metrics::{ + ConnectionType, Direction, P2P_METRICS, PeerConnectionLabels, PeerNetworkLabels, + PeerStreamLabels, Protocol, RelayConnectionLabels, +}; +use vise_exporter::MetricsExporter; + +#[tokio::main] +async fn main() { + let bind_address = SocketAddr::from(([0, 0, 0, 0], 9464)); + + let exporter = MetricsExporter::default() + .bind(bind_address) + .await + .expect("Failed to bind metrics exporter"); + tokio::spawn(async move { + exporter + .start() + .await + .expect("Failed to start metrics exporter"); + }); + + P2P_METRICS.ping_latency_secs["rustnew"].observe(1.0); + P2P_METRICS.ping_error_total["rustnew"].inc(); + P2P_METRICS.ping_success["rustnew"].set(1); + P2P_METRICS.reachability_status.set(1); + P2P_METRICS.relay_connections["rustnew"].set(1); + P2P_METRICS.peer_connection_types + [&PeerConnectionLabels::new("rustnew", ConnectionType::Direct, Protocol::Tcp)] + .set(1); + P2P_METRICS.relay_connection_types + [&RelayConnectionLabels::new("rustnew", ConnectionType::Direct, Protocol::Tcp)] + .set(1); + P2P_METRICS.peer_streams[&PeerStreamLabels::new("rustnew", Direction::Inbound, Protocol::Tcp)] + .set(1); + P2P_METRICS.peer_connection_total["rustnew"].inc(); + P2P_METRICS.peer_network_receive_bytes_total[&PeerNetworkLabels::new("rustnew", Protocol::Tcp)] + .inc(); + P2P_METRICS.peer_network_sent_bytes_total[&PeerNetworkLabels::new("rustnew", Protocol::Tcp)] + .inc(); + + // Wait for 10 seconds to see the logs in Loki + std::thread::sleep(std::time::Duration::from_secs(20)); +} diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 63312162..98b71497 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -13,3 +13,6 @@ pub mod name; /// P2P configuration. pub mod config; + +/// Metrics. +pub mod metrics; diff --git a/crates/charon-p2p/src/metrics.rs b/crates/charon-p2p/src/metrics.rs new file mode 100644 index 00000000..bfadff76 --- /dev/null +++ b/crates/charon-p2p/src/metrics.rs @@ -0,0 +1,149 @@ +use vise::*; + +const BUCKETS: [f64; 11] = [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +]; + +/// Metrics for the P2P layer. +#[derive(Debug, Metrics)] +#[metrics(prefix = "p2p")] +pub struct P2PMetrics { + /// Ping latencies in seconds per peer + #[metrics(buckets = &BUCKETS, labels = ["peer"])] + pub ping_latency_secs: LabeledFamily, + + /// Total number of ping errors per peer + #[metrics(labels = ["peer"])] + pub ping_error_total: LabeledFamily, + + /// Whether the last ping was successful (1) or not (0). Can be used as + /// proxy for connected peers + #[metrics(labels = ["peer"])] + pub ping_success: LabeledFamily, + + /// Current libp2p reachability status of this node as detected by autonat: + /// unknown(0), public(1) or private(2). + pub reachability_status: Gauge, + + /// Connected relays by name + #[metrics(labels = ["peer"])] + pub relay_connections: LabeledFamily, + + /// Current number of libp2p connections by peer, type (`direct` or + /// `relay`), and protocol (`tcp`, `quic`). Note that peers may have + /// multiple connections. + pub peer_connection_types: Family, + + /// Current number of libp2p connections by relay, type (`direct` or + /// `relay`), and protocol (`tcp`, `quic`). Note that peers may have + /// multiple connections. + pub relay_connection_types: Family, + + /// Current number of libp2p streams by peer, direction ('inbound' or + /// 'outbound' or 'unknown') and protocol. + pub peer_streams: Family, + + /// Total number of libp2p connections per peer. + #[metrics(labels = ["peer"])] + pub peer_connection_total: LabeledFamily, + + /// Total number of network bytes received from the peer by protocol. + pub peer_network_receive_bytes_total: Family, + + /// Total number of network bytes sent to the peer by protocol. + pub peer_network_sent_bytes_total: Family, +} + +/// The type of connection. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelValue)] +#[metrics(rename_all = "snake_case")] +pub enum ConnectionType { + /// A direct connection to a peer. + Direct, + /// A connection to a relay. + Relay, +} + +/// The direction of a connection. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelValue)] +#[metrics(rename_all = "snake_case")] +pub enum Direction { + /// An inbound connection. + Inbound, + /// An outbound connection. + Outbound, + /// An unknown connection. + Unknown, +} + +/// The protocol of a connection. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelValue)] +#[metrics(rename_all = "snake_case")] +pub enum Protocol { + /// A TCP connection. + Tcp, + /// A QUIC connection. + Quic, +} + +/// Labels for peer connections. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelSet)] +pub struct PeerConnectionLabels { + peer: String, + r#type: ConnectionType, + protocol: Protocol, +} + +impl PeerConnectionLabels { + /// Creates a new peer connection labels. + pub fn new(peer: &str, r#type: ConnectionType, protocol: Protocol) -> Self { + Self { + peer: peer.to_string(), + r#type, + protocol, + } + } +} + +/// Relay connection labels +pub type RelayConnectionLabels = PeerConnectionLabels; + +/// Labels for peer streams. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelSet)] +pub struct PeerStreamLabels { + peer: String, + direction: Direction, + protocol: Protocol, +} + +impl PeerStreamLabels { + /// Creates a new peer stream labels. + pub fn new(peer: &str, direction: Direction, protocol: Protocol) -> Self { + Self { + peer: peer.to_string(), + direction, + protocol, + } + } +} + +/// Labels for peer network. +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelSet)] +pub struct PeerNetworkLabels { + peer: String, + protocol: Protocol, +} + +impl PeerNetworkLabels { + /// Creates a new peer network labels. + pub fn new(peer: &str, protocol: Protocol) -> Self { + Self { + peer: peer.to_string(), + protocol, + } + } +} + +/// Global metrics for the P2P layer. +#[vise::register] +pub static P2P_METRICS: Global = Global::new(); diff --git a/crates/tracing/examples/test-infra/docker-compose.yml b/test-infra/docker-compose.yml similarity index 100% rename from crates/tracing/examples/test-infra/docker-compose.yml rename to test-infra/docker-compose.yml diff --git a/crates/tracing/examples/test-infra/prometheus.yml b/test-infra/prometheus.yml similarity index 100% rename from crates/tracing/examples/test-infra/prometheus.yml rename to test-infra/prometheus.yml diff --git a/crates/tracing/examples/test-infra/promtail-config.yml b/test-infra/promtail-config.yml similarity index 100% rename from crates/tracing/examples/test-infra/promtail-config.yml rename to test-infra/promtail-config.yml From c432d86745250b321f312d083c15149ae1411b8d Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:34:10 +0200 Subject: [PATCH 06/24] fix: rename label in example --- crates/charon-p2p/examples/metrics.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/charon-p2p/examples/metrics.rs b/crates/charon-p2p/examples/metrics.rs index e9ccbb50..75d28fed 100644 --- a/crates/charon-p2p/examples/metrics.rs +++ b/crates/charon-p2p/examples/metrics.rs @@ -35,23 +35,23 @@ async fn main() { .expect("Failed to start metrics exporter"); }); - P2P_METRICS.ping_latency_secs["rustnew"].observe(1.0); - P2P_METRICS.ping_error_total["rustnew"].inc(); - P2P_METRICS.ping_success["rustnew"].set(1); + P2P_METRICS.ping_latency_secs["rust"].observe(1.0); + P2P_METRICS.ping_error_total["rust"].inc(); + P2P_METRICS.ping_success["rust"].set(1); P2P_METRICS.reachability_status.set(1); - P2P_METRICS.relay_connections["rustnew"].set(1); + P2P_METRICS.relay_connections["rust"].set(1); P2P_METRICS.peer_connection_types - [&PeerConnectionLabels::new("rustnew", ConnectionType::Direct, Protocol::Tcp)] + [&PeerConnectionLabels::new("rust", ConnectionType::Direct, Protocol::Tcp)] .set(1); P2P_METRICS.relay_connection_types - [&RelayConnectionLabels::new("rustnew", ConnectionType::Direct, Protocol::Tcp)] + [&RelayConnectionLabels::new("rust", ConnectionType::Direct, Protocol::Tcp)] .set(1); - P2P_METRICS.peer_streams[&PeerStreamLabels::new("rustnew", Direction::Inbound, Protocol::Tcp)] + P2P_METRICS.peer_streams[&PeerStreamLabels::new("rust", Direction::Inbound, Protocol::Tcp)] .set(1); - P2P_METRICS.peer_connection_total["rustnew"].inc(); - P2P_METRICS.peer_network_receive_bytes_total[&PeerNetworkLabels::new("rustnew", Protocol::Tcp)] + P2P_METRICS.peer_connection_total["rust"].inc(); + P2P_METRICS.peer_network_receive_bytes_total[&PeerNetworkLabels::new("rust", Protocol::Tcp)] .inc(); - P2P_METRICS.peer_network_sent_bytes_total[&PeerNetworkLabels::new("rustnew", Protocol::Tcp)] + P2P_METRICS.peer_network_sent_bytes_total[&PeerNetworkLabels::new("rust", Protocol::Tcp)] .inc(); // Wait for 10 seconds to see the logs in Loki From aeaa9710c3ea1e033419bef672db170e74f0341e Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:35:35 +0200 Subject: [PATCH 07/24] fix: linting --- crates/charon-p2p/examples/metrics.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/charon-p2p/examples/metrics.rs b/crates/charon-p2p/examples/metrics.rs index 75d28fed..99472c53 100644 --- a/crates/charon-p2p/examples/metrics.rs +++ b/crates/charon-p2p/examples/metrics.rs @@ -51,8 +51,7 @@ async fn main() { P2P_METRICS.peer_connection_total["rust"].inc(); P2P_METRICS.peer_network_receive_bytes_total[&PeerNetworkLabels::new("rust", Protocol::Tcp)] .inc(); - P2P_METRICS.peer_network_sent_bytes_total[&PeerNetworkLabels::new("rust", Protocol::Tcp)] - .inc(); + P2P_METRICS.peer_network_sent_bytes_total[&PeerNetworkLabels::new("rust", Protocol::Tcp)].inc(); // Wait for 10 seconds to see the logs in Loki std::thread::sleep(std::time::Duration::from_secs(20)); From a82bbc7e30e8a1b6fccdbe4d22104a5aee0c9dff Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Thu, 18 Dec 2025 18:52:01 +0200 Subject: [PATCH 08/24] feat: [wip] add relay/ping --- Cargo.lock | 1 + Cargo.toml | 1 + crates/charon-p2p/Cargo.toml | 4 + crates/charon-p2p/examples/p2p.rs | 139 +++++++++++++++++++++++++++ crates/charon-p2p/src/gater.rs | 4 + crates/charon-p2p/src/lib.rs | 6 ++ crates/charon-p2p/src/p2p.rs | 154 ++++++++++++++++++++++++++++++ 7 files changed, 309 insertions(+) create mode 100644 crates/charon-p2p/examples/p2p.rs create mode 100644 crates/charon-p2p/src/gater.rs create mode 100644 crates/charon-p2p/src/p2p.rs diff --git a/Cargo.lock b/Cargo.lock index 37f20a2b..93832975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -556,6 +556,7 @@ dependencies = [ name = "charon-p2p" version = "0.1.0" dependencies = [ + "anyhow", "charon-eth2", "charon-k1util", "charon-testutil", diff --git a/Cargo.toml b/Cargo.toml index eafea64e..25d8038c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ license = "Apache-2.0" # TODO(template) updat publish = false [workspace.dependencies] +anyhow = "*" # for testing purposes only axum = "0.8.6" chrono = { version = "0.4", features = ["serde"] } hex = { version = "^0.4.3" } diff --git a/crates/charon-p2p/Cargo.toml b/crates/charon-p2p/Cargo.toml index 274057bb..625d52c2 100644 --- a/crates/charon-p2p/Cargo.toml +++ b/crates/charon-p2p/Cargo.toml @@ -18,6 +18,10 @@ tokio.workspace = true [dev-dependencies] charon-testutil.workspace = true vise-exporter.workspace = true +anyhow.workspace = true [lints] workspace = true + +[features] +mdns = ["libp2p/mdns"] \ No newline at end of file diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs new file mode 100644 index 00000000..61999341 --- /dev/null +++ b/crates/charon-p2p/examples/p2p.rs @@ -0,0 +1,139 @@ +#![allow(missing_docs)] + +use std::net::Ipv4Addr; + +use anyhow::Result; +use charon_eth2::enr::{Record, with_ip_impl, with_tcp_impl, with_udp_impl}; +use charon_p2p::{ + config::P2PConfig, + gater::ConnGater, + peer::peer_id_from_key, + p2p::{Node, NodeType, PlutoBehavior, PlutoBehaviorEvent}, +}; +use k256::elliptic_curve::rand_core::OsRng; +use libp2p::{Multiaddr, futures::StreamExt, identify, swarm::SwarmEvent}; +use tokio::signal; + +#[tokio::main] +async fn main() -> Result<()> { + let key = k256::SecretKey::random(&mut OsRng); + let mut p2p: Node = Node::new( + P2PConfig::default(), + key.clone(), + ConnGater, + false, + NodeType::QUIC, + PlutoBehavior::new, + ); + + let swarm = &mut p2p.swarm; + + // Get port from environment variable or default to 1050 + let port = std::env::var("PORT") + .ok() + .and_then(|p| p.parse::().ok()) + .unwrap_or(1050); + + let enr = Record::new( + key.clone(), + vec![ + with_tcp_impl(port), + with_udp_impl(port), + with_ip_impl(Ipv4Addr::new(0, 0, 0, 0)), + ], + ) + .unwrap(); + + println!("ENR: {}", enr); + + swarm.listen_on(format!("/ip4/0.0.0.0/udp/{}/quic-v1", port).parse()?)?; + swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{}", port).parse()?)?; + + // Fetch peers from CLI arguments (ENR strings) + // Usage: cargo run --example p2p -- ... + for enr_str in std::env::args().skip(1) { + match Record::try_from(enr_str.as_str()) { + Ok(enr) => { + println!("Adding peer: {:?}", enr); + // Extract public key and convert to PeerId + let Some(public_key) = enr.public_key else { + eprintln!("ENR missing public key"); + continue; + }; + + let peer_id = match peer_id_from_key(public_key) { + Ok(peer_id) => peer_id, + Err(e) => { + eprintln!("Failed to convert ENR public key to PeerId: {}", e); + continue; + } + }; + + // Extract IP and ports from ENR + let ip = enr.ip().unwrap_or(Ipv4Addr::new(0, 0, 0, 0)); + + // Try to add TCP address if available + let tcp_port = enr.tcp().unwrap_or(3610); + let udp_port = enr.udp().unwrap_or(3610); + + if enr.tcp().is_none() && enr.udp().is_none() { + eprintln!("ENR missing both TCP and UDP ports"); + } + + swarm.add_peer_address(peer_id, format!("/ip4/{}/udp/{}", ip, udp_port).parse().unwrap()); + swarm.add_peer_address(peer_id, format!("/ip4/{}/tcp/{}", ip, tcp_port).parse().unwrap()); + } + Err(e) => { + eprintln!("Failed to parse ENR: {} (error: {})", enr_str, e); + } + } + } + + loop { + tokio::select! { + event = swarm.select_next_some() => match event { + SwarmEvent::Behaviour(PlutoBehaviorEvent::Relay(event)) => { + println!("Got relay event: {:?}", event); + }, + SwarmEvent::Behaviour(PlutoBehaviorEvent::Identify(identify::Event::Received { + info: identify::Info { observed_addr, ..}, .. + })) => { + println!("Address observed {}", observed_addr); + } + SwarmEvent::Behaviour(PlutoBehaviorEvent::Mdns(libp2p::mdns::Event::Discovered(nodes))) => { + for node in nodes { + println!("Discovered node: {:?}", node); + swarm.dial(node.1).unwrap(); + } + } + SwarmEvent::NewListenAddr { address, .. } => { + println!("Local node is listening on {address}"); + } + SwarmEvent::Behaviour(PlutoBehaviorEvent::Ping(ping_event)) => { + println!("Got ping event: {:?}", ping_event); + } + SwarmEvent::IncomingConnection { connection_id, local_addr, send_back_addr } => { + println!("Incoming connection (id={connection_id}) from {:?} (send on {:?})", local_addr, send_back_addr); + } + SwarmEvent::IncomingConnectionError {peer_id,connection_id,error, local_addr, send_back_addr } => { + println!("Incoming connection (id={connection_id}) error from {:?} (send on {:?} to {:?}): {:?}", peer_id, local_addr, send_back_addr, error); + } + event => { + println!("{:?}", event); + } + }, + _ = signal::ctrl_c() => { + println!("\nReceived Ctrl+C, shutting down gracefully..."); + + // Perform cleanup + let _ = swarm; + drop(p2p); + + println!("Shutdown complete"); + break; + } + } + } + + Ok(()) +} diff --git a/crates/charon-p2p/src/gater.rs b/crates/charon-p2p/src/gater.rs new file mode 100644 index 00000000..e56aec61 --- /dev/null +++ b/crates/charon-p2p/src/gater.rs @@ -0,0 +1,4 @@ +//! Gater is responsible for whitelisting / blacklisting peers + +/// todo: Temporary empty +pub struct ConnGater; diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 98b71497..7ad67fe3 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -16,3 +16,9 @@ pub mod config; /// Metrics. pub mod metrics; + +/// P2P. +pub mod p2p; + +/// Gater +pub mod gater; diff --git a/crates/charon-p2p/src/p2p.rs b/crates/charon-p2p/src/p2p.rs new file mode 100644 index 00000000..fa214a0e --- /dev/null +++ b/crates/charon-p2p/src/p2p.rs @@ -0,0 +1,154 @@ +#![allow(missing_docs)] +#![allow(dead_code)] +#![allow(unused)] + +//! P2P core concepts + +use std::{sync::Once, time::Duration}; + +use libp2p::{ + Swarm, SwarmBuilder, identify, + identity::Keypair, + noise, ping, relay, + swarm::{NetworkBehaviour, SwarmEvent}, + tcp, yamux, +}; + +use libp2p::mdns; + +use crate::{config::P2PConfig, gater::ConnGater}; + +pub enum NodeType { + TCP, + QUIC, +} + +#[derive(NetworkBehaviour)] +pub struct PlutoBehavior { + pub relay: relay::Behaviour, + pub identify: identify::Behaviour, + pub ping: ping::Behaviour, + pub mdns: mdns::tokio::Behaviour, +} + +pub trait LoopBehavior { + fn spawn_loop(&self) -> impl Future; +} + +impl LoopBehavior for Node { + async fn spawn_loop(&self) {} +} + +impl PlutoBehavior { + pub fn new(key: &Keypair) -> Self { + Self { + relay: relay::Behaviour::new(key.public().to_peer_id(), Default::default()), + identify: identify::Behaviour::new(identify::Config::new( + "/pluto/1.0.0-alpha".into(), + key.public(), + )), + ping: ping::Behaviour::new( + ping::Config::new() + .with_interval(Duration::from_secs(1)) + .with_timeout(Duration::from_secs(2)), + ), + mdns: mdns::tokio::Behaviour::new(mdns::Config::default(), key.public().to_peer_id()) + .unwrap(), + } + } +} + +pub struct Node { + pub swarm: Swarm, + pub callbacks: Vec) + Send + Sync>>, +} + +impl Node { + pub fn new( + cfg: P2PConfig, + key: k256::SecretKey, + conn_gater: ConnGater, + filter_private_addrs: bool, + node_type: NodeType, + behavior_fn: F, + ) -> Self + where + F: Fn(&Keypair) -> B, + { + match node_type { + NodeType::TCP => { + Self::new_with_tcp(cfg, key, conn_gater, filter_private_addrs, behavior_fn) + } + NodeType::QUIC => { + Self::new_with_quic(cfg, key, conn_gater, filter_private_addrs, behavior_fn) + } + } + } + + pub fn new_with_quic( + cfg: P2PConfig, + key: k256::SecretKey, + conn_gater: ConnGater, + filter_private_addrs: bool, + behavior_fn: F, + ) -> Self + where + F: Fn(&Keypair) -> B, + { + let mut der = key.to_sec1_der().unwrap(); + let keypair = Keypair::secp256k1_from_der(&mut der).unwrap(); + + let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) + .with_tokio() + .with_tcp( + tcp::Config::default(), + noise::Config::new, + yamux::Config::default, + ) + .unwrap() + .with_quic() + .with_behaviour(behavior_fn) + .unwrap() + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) + .build(); + + Node { + swarm, + callbacks: vec![], + } + } + + pub fn new_with_tcp( + cfg: P2PConfig, + key: k256::SecretKey, + conn_gater: ConnGater, + filter_private_addrs: bool, + behavior_fn: F, + ) -> Self + where + F: Fn(&Keypair) -> B, + { + let mut der = key.to_sec1_der().unwrap(); + let keypair = Keypair::secp256k1_from_der(&mut der).unwrap(); + + let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) + .with_tokio() + .with_tcp( + tcp::Config::default(), + noise::Config::new, + yamux::Config::default, + ) + .unwrap() + .with_behaviour(behavior_fn) + .unwrap() + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) + .build(); + + Node { + swarm, + callbacks: vec![], + } + } + + pub fn add_callback(&mut self, callback: Box) + Send + Sync>) {} +} From a2fe791e19e7e6c955ee89fc5f36e95b1ccdea9d Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:35:59 +0200 Subject: [PATCH 09/24] feat: add behaviours module, add relay client support --- Cargo.lock | 84 ++++++++++ Cargo.toml | 1 + crates/charon-p2p/Cargo.toml | 8 +- crates/charon-p2p/examples/p2p.rs | 157 +++++++++--------- crates/charon-p2p/src/behaviours/mod.rs | 9 + crates/charon-p2p/src/behaviours/pluto.rs | 33 ++++ .../charon-p2p/src/behaviours/pluto_mdns.rs | 24 +++ crates/charon-p2p/src/config.rs | 2 +- crates/charon-p2p/src/lib.rs | 3 + crates/charon-p2p/src/p2p.rs | 125 +++++++------- 10 files changed, 304 insertions(+), 142 deletions(-) create mode 100644 crates/charon-p2p/src/behaviours/mod.rs create mode 100644 crates/charon-p2p/src/behaviours/pluto.rs create mode 100644 crates/charon-p2p/src/behaviours/pluto_mdns.rs diff --git a/Cargo.lock b/Cargo.lock index 93832975..20b09543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,12 +79,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -560,6 +604,7 @@ dependencies = [ "charon-eth2", "charon-k1util", "charon-testutil", + "clap", "k256", "libp2p", "thiserror 2.0.17", @@ -650,6 +695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -658,8 +704,22 @@ version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -668,6 +728,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "compile-fmt" version = "0.1.0" @@ -1944,6 +2010,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -3110,6 +3182,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -4868,6 +4946,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.19.0" diff --git a/Cargo.toml b/Cargo.toml index 25d8038c..40660c5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ publish = false anyhow = "*" # for testing purposes only axum = "0.8.6" chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.5.53", features = ["derive"] } hex = { version = "^0.4.3" } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "^1.0" } diff --git a/crates/charon-p2p/Cargo.toml b/crates/charon-p2p/Cargo.toml index 625d52c2..9e08d6d8 100644 --- a/crates/charon-p2p/Cargo.toml +++ b/crates/charon-p2p/Cargo.toml @@ -19,9 +19,15 @@ tokio.workspace = true charon-testutil.workspace = true vise-exporter.workspace = true anyhow.workspace = true +clap.workspace = true +charon-k1util.workspace = true [lints] workspace = true [features] -mdns = ["libp2p/mdns"] \ No newline at end of file +mdns = ["libp2p/mdns"] + +[[example]] +name = "p2p" +required-features = ["mdns"] \ No newline at end of file diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs index 61999341..2b3b3915 100644 --- a/crates/charon-p2p/examples/p2p.rs +++ b/crates/charon-p2p/examples/p2p.rs @@ -1,115 +1,124 @@ -#![allow(missing_docs)] - -use std::net::Ipv4Addr; +//! P2P example +//! +//! This example creates a Pluto P2P node and connects to a relay. +//! Also, it discovers other Pluto nodes using mDNS (requires the `mdns` +//! feature). use anyhow::Result; -use charon_eth2::enr::{Record, with_ip_impl, with_tcp_impl, with_udp_impl}; +use charon_eth2::enr::Record; use charon_p2p::{ + behaviours::pluto::{PlutoBehaviourEvent}, + behaviours::pluto_mdns::{PlutoMdnsBehaviour, PlutoMdnsBehaviourEvent}, config::P2PConfig, gater::ConnGater, - peer::peer_id_from_key, - p2p::{Node, NodeType, PlutoBehavior, PlutoBehaviorEvent}, + p2p::{Node, NodeType}, }; +use clap::Parser; use k256::elliptic_curve::rand_core::OsRng; -use libp2p::{Multiaddr, futures::StreamExt, identify, swarm::SwarmEvent}; +use libp2p::{Multiaddr, futures::StreamExt, identify, multiaddr::Protocol, swarm::SwarmEvent}; use tokio::signal; +/// Command line arguments +#[derive(Debug, Parser)] +pub struct Args { + /// The port to listen on + #[arg(short, long, default_value = "1050")] + pub port: u16, + /// The ENRs to listen on + #[arg(short, long)] + pub enrs: Vec, + /// The relay URL to dial + #[arg(short, long)] + pub relay_url: Option, +} + #[tokio::main] async fn main() -> Result<()> { let key = k256::SecretKey::random(&mut OsRng); - let mut p2p: Node = Node::new( + let mut p2p: Node<_> = Node::new( P2PConfig::default(), key.clone(), ConnGater, false, NodeType::QUIC, - PlutoBehavior::new, - ); - - let swarm = &mut p2p.swarm; - - // Get port from environment variable or default to 1050 - let port = std::env::var("PORT") - .ok() - .and_then(|p| p.parse::().ok()) - .unwrap_or(1050); - - let enr = Record::new( - key.clone(), - vec![ - with_tcp_impl(port), - with_udp_impl(port), - with_ip_impl(Ipv4Addr::new(0, 0, 0, 0)), - ], - ) - .unwrap(); - - println!("ENR: {}", enr); - - swarm.listen_on(format!("/ip4/0.0.0.0/udp/{}/quic-v1", port).parse()?)?; - swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{}", port).parse()?)?; - - // Fetch peers from CLI arguments (ENR strings) - // Usage: cargo run --example p2p -- ... - for enr_str in std::env::args().skip(1) { - match Record::try_from(enr_str.as_str()) { - Ok(enr) => { - println!("Adding peer: {:?}", enr); - // Extract public key and convert to PeerId - let Some(public_key) = enr.public_key else { - eprintln!("ENR missing public key"); - continue; - }; - - let peer_id = match peer_id_from_key(public_key) { - Ok(peer_id) => peer_id, - Err(e) => { - eprintln!("Failed to convert ENR public key to PeerId: {}", e); - continue; - } - }; + PlutoMdnsBehaviour::new, + )?; - // Extract IP and ports from ENR - let ip = enr.ip().unwrap_or(Ipv4Addr::new(0, 0, 0, 0)); + let args = Args::parse(); - // Try to add TCP address if available - let tcp_port = enr.tcp().unwrap_or(3610); - let udp_port = enr.udp().unwrap_or(3610); + let swarm = &mut p2p.swarm; - if enr.tcp().is_none() && enr.udp().is_none() { - eprintln!("ENR missing both TCP and UDP ports"); + let enr = Record::new(key.clone(), vec![])?; + + if let Some(relay_url) = &args.relay_url { + swarm.dial(relay_url.clone())?; + println!("Dialed relay"); + let mut learned_observed_addr = false; + let mut told_relay_observed_addr = false; + + loop { + match swarm + .next() + .await + .ok_or(anyhow::anyhow!("Swarm event is None"))? + { + SwarmEvent::NewListenAddr { .. } => {} + SwarmEvent::Dialing { .. } => {} + SwarmEvent::ConnectionEstablished { .. } => {} + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto( + PlutoBehaviourEvent::Ping(_), + )) => {} + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto( + PlutoBehaviourEvent::Identify(identify::Event::Sent { .. }), + )) => { + println!("Told relay its public address"); + told_relay_observed_addr = true; } - - swarm.add_peer_address(peer_id, format!("/ip4/{}/udp/{}", ip, udp_port).parse().unwrap()); - swarm.add_peer_address(peer_id, format!("/ip4/{}/tcp/{}", ip, tcp_port).parse().unwrap()); + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto( + PlutoBehaviourEvent::Identify(identify::Event::Received { + info: identify::Info { observed_addr, .. }, + .. + }), + )) => { + println!("Relay told us our observed address: {}", observed_addr); + learned_observed_addr = true; + } + event => panic!("{event:?}"), } - Err(e) => { - eprintln!("Failed to parse ENR: {} (error: {})", enr_str, e); + if learned_observed_addr && told_relay_observed_addr { + break; } } } + println!("ENR: {}", enr); + + swarm.listen_on(format!("/ip4/0.0.0.0/udp/{}/quic-v1", args.port).parse()?)?; + swarm.listen_on(format!("/ip4/0.0.0.0/tcp/{}", args.port).parse()?)?; + if let Some(relay_url) = args.relay_url { + swarm.listen_on(relay_url.with(Protocol::P2pCircuit))?; + } + loop { tokio::select! { event = swarm.select_next_some() => match event { - SwarmEvent::Behaviour(PlutoBehaviorEvent::Relay(event)) => { - println!("Got relay event: {:?}", event); - }, - SwarmEvent::Behaviour(PlutoBehaviorEvent::Identify(identify::Event::Received { - info: identify::Info { observed_addr, ..}, .. - })) => { + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto(PlutoBehaviourEvent::Identify(identify::Event::Received { info: identify::Info { observed_addr, .. }, .. }))) => { + swarm.add_external_address(observed_addr.clone()); println!("Address observed {}", observed_addr); } - SwarmEvent::Behaviour(PlutoBehaviorEvent::Mdns(libp2p::mdns::Event::Discovered(nodes))) => { + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto(PlutoBehaviourEvent::Relay(event))) => { + println!("Got relay event: {:?}", event); + }, + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Mdns(libp2p::mdns::Event::Discovered(nodes))) => { for node in nodes { println!("Discovered node: {:?}", node); - swarm.dial(node.1).unwrap(); + swarm.dial(node.1)?; } } SwarmEvent::NewListenAddr { address, .. } => { println!("Local node is listening on {address}"); } - SwarmEvent::Behaviour(PlutoBehaviorEvent::Ping(ping_event)) => { + SwarmEvent::Behaviour(PlutoMdnsBehaviourEvent::Pluto(PlutoBehaviourEvent::Ping(ping_event))) => { println!("Got ping event: {:?}", ping_event); } SwarmEvent::IncomingConnection { connection_id, local_addr, send_back_addr } => { diff --git a/crates/charon-p2p/src/behaviours/mod.rs b/crates/charon-p2p/src/behaviours/mod.rs new file mode 100644 index 00000000..5a16f8ed --- /dev/null +++ b/crates/charon-p2p/src/behaviours/mod.rs @@ -0,0 +1,9 @@ +//! Behaviours. +#![allow(missing_docs)] // we need to allow missing docs for the derive macro + +/// Pluto behaviour. +pub mod pluto; + +#[cfg(feature = "mdns")] +/// Pluto Mdns behaviour. +pub mod pluto_mdns; diff --git a/crates/charon-p2p/src/behaviours/pluto.rs b/crates/charon-p2p/src/behaviours/pluto.rs new file mode 100644 index 00000000..63a593b3 --- /dev/null +++ b/crates/charon-p2p/src/behaviours/pluto.rs @@ -0,0 +1,33 @@ +//! Pluto behaviour. + +use std::time::Duration; + +use libp2p::{identify, identity::Keypair, ping, relay, swarm::NetworkBehaviour}; + +#[derive(NetworkBehaviour)] +pub struct PlutoBehaviour { + /// Relay client behaviour. + pub relay: relay::client::Behaviour, + /// Identify behaviour. + pub identify: identify::Behaviour, + /// Ping behaviour. + pub ping: ping::Behaviour, +} + +impl PlutoBehaviour { + /// Creates a new Pluto behaviour. + pub fn new(key: &Keypair, relay_client: relay::client::Behaviour) -> Self { + Self { + relay: relay_client, + identify: identify::Behaviour::new(identify::Config::new( + "/pluto/1.0.0-alpha".into(), + key.public(), + )), + ping: ping::Behaviour::new( + ping::Config::new() + .with_interval(Duration::from_secs(1)) + .with_timeout(Duration::from_secs(2)), + ), + } + } +} diff --git a/crates/charon-p2p/src/behaviours/pluto_mdns.rs b/crates/charon-p2p/src/behaviours/pluto_mdns.rs new file mode 100644 index 00000000..f52a9559 --- /dev/null +++ b/crates/charon-p2p/src/behaviours/pluto_mdns.rs @@ -0,0 +1,24 @@ +//! Pluto Mdns behaviour. +use libp2p::{identity::Keypair, mdns, relay, swarm::NetworkBehaviour}; + +use crate::behaviours::pluto::PlutoBehaviour; + +/// Pluto network behaviour. +#[derive(NetworkBehaviour)] +pub struct PlutoMdnsBehaviour { + /// Pluto behaviour. + pub pluto: PlutoBehaviour, + /// Mdns behaviour. + pub mdns: mdns::tokio::Behaviour, +} + +impl PlutoMdnsBehaviour { + /// Creates a new Pluto Mdns behaviour. + pub fn new(key: &Keypair, relay_client: relay::client::Behaviour) -> Self { + Self { + pluto: PlutoBehaviour::new(key, relay_client), + mdns: mdns::tokio::Behaviour::new(mdns::Config::default(), key.public().to_peer_id()) + .unwrap(), + } + } +} diff --git a/crates/charon-p2p/src/config.rs b/crates/charon-p2p/src/config.rs index ffe15a1b..10778ef2 100644 --- a/crates/charon-p2p/src/config.rs +++ b/crates/charon-p2p/src/config.rs @@ -56,7 +56,7 @@ type Result = std::result::Result; #[derive(Debug, Clone, Default)] pub struct P2PConfig { /// Defines the libp2p relay multiaddrs or URLs. - pub relays: Vec, + pub relays: Vec, /// The external IP address of the node. pub external_ip: String, diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 7ad67fe3..8229da74 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -22,3 +22,6 @@ pub mod p2p; /// Gater pub mod gater; + +/// Behaviours. +pub mod behaviours; diff --git a/crates/charon-p2p/src/p2p.rs b/crates/charon-p2p/src/p2p.rs index fa214a0e..b9e3a22b 100644 --- a/crates/charon-p2p/src/p2p.rs +++ b/crates/charon-p2p/src/p2p.rs @@ -18,49 +18,34 @@ use libp2p::mdns; use crate::{config::P2PConfig, gater::ConnGater}; -pub enum NodeType { - TCP, - QUIC, -} +#[derive(Debug, thiserror::Error)] +pub enum P2PError { + /// Failed to build the swarm. + #[error("Failed to build the swarm: {0}")] + FailedToBuildSwarm(Box), -#[derive(NetworkBehaviour)] -pub struct PlutoBehavior { - pub relay: relay::Behaviour, - pub identify: identify::Behaviour, - pub ping: ping::Behaviour, - pub mdns: mdns::tokio::Behaviour, -} + #[error("Failed to convert the secret key to a libp2p keypair: {0}")] + FailedToConvertSecretKeyToLibp2pKeypair(#[from] k256::pkcs8::der::Error), -pub trait LoopBehavior { - fn spawn_loop(&self) -> impl Future; + #[error("Failed to decode the libp2p keypair: {0}")] + FailedToDecodeLibp2pKeypair(#[from] libp2p::identity::DecodingError), } -impl LoopBehavior for Node { - async fn spawn_loop(&self) {} +impl P2PError { + pub fn failed_to_build_swarm(error: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::FailedToBuildSwarm(Box::new(error)) + } } -impl PlutoBehavior { - pub fn new(key: &Keypair) -> Self { - Self { - relay: relay::Behaviour::new(key.public().to_peer_id(), Default::default()), - identify: identify::Behaviour::new(identify::Config::new( - "/pluto/1.0.0-alpha".into(), - key.public(), - )), - ping: ping::Behaviour::new( - ping::Config::new() - .with_interval(Duration::from_secs(1)) - .with_timeout(Duration::from_secs(2)), - ), - mdns: mdns::tokio::Behaviour::new(mdns::Config::default(), key.public().to_peer_id()) - .unwrap(), - } - } +type Result = std::result::Result; + +pub enum NodeType { + TCP, + QUIC, } pub struct Node { pub swarm: Swarm, - pub callbacks: Vec) + Send + Sync>>, } impl Node { @@ -70,52 +55,61 @@ impl Node { conn_gater: ConnGater, filter_private_addrs: bool, node_type: NodeType, - behavior_fn: F, - ) -> Self + behaviour_fn: F, + ) -> Result where - F: Fn(&Keypair) -> B, + F: Fn(&Keypair, relay::client::Behaviour) -> B, { match node_type { NodeType::TCP => { - Self::new_with_tcp(cfg, key, conn_gater, filter_private_addrs, behavior_fn) + Self::new_with_tcp(cfg, key, conn_gater, filter_private_addrs, behaviour_fn) } NodeType::QUIC => { - Self::new_with_quic(cfg, key, conn_gater, filter_private_addrs, behavior_fn) + Self::new_with_quic(cfg, key, conn_gater, filter_private_addrs, behaviour_fn) } } } + fn default_swarm_config(cfg: libp2p::swarm::Config) -> libp2p::swarm::Config { + cfg.with_idle_connection_timeout(Duration::from_secs(300)) + } + + fn default_tcp_config() -> tcp::Config { + tcp::Config::default() + } + pub fn new_with_quic( cfg: P2PConfig, key: k256::SecretKey, conn_gater: ConnGater, filter_private_addrs: bool, - behavior_fn: F, - ) -> Self + behaviour_fn: F, + ) -> Result where - F: Fn(&Keypair) -> B, + F: Fn(&Keypair, relay::client::Behaviour) -> B, { - let mut der = key.to_sec1_der().unwrap(); - let keypair = Keypair::secp256k1_from_der(&mut der).unwrap(); + let mut der = key.to_sec1_der()?; + let keypair = Keypair::secp256k1_from_der(&mut der)?; let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) .with_tokio() .with_tcp( - tcp::Config::default(), + Self::default_tcp_config(), noise::Config::new, yamux::Config::default, ) - .unwrap() + .map_err(P2PError::failed_to_build_swarm)? .with_quic() - .with_behaviour(behavior_fn) - .unwrap() - .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) + .with_dns() + .map_err(P2PError::failed_to_build_swarm)? + .with_relay_client(noise::Config::new, yamux::Config::default) + .map_err(P2PError::failed_to_build_swarm)? + .with_behaviour(behaviour_fn) + .map_err(P2PError::failed_to_build_swarm)? + .with_swarm_config(Self::default_swarm_config) .build(); - Node { - swarm, - callbacks: vec![], - } + Ok(Node { swarm }) } pub fn new_with_tcp( @@ -123,10 +117,10 @@ impl Node { key: k256::SecretKey, conn_gater: ConnGater, filter_private_addrs: bool, - behavior_fn: F, - ) -> Self + behaviour_fn: F, + ) -> Result where - F: Fn(&Keypair) -> B, + F: Fn(&Keypair, relay::client::Behaviour) -> B, { let mut der = key.to_sec1_der().unwrap(); let keypair = Keypair::secp256k1_from_der(&mut der).unwrap(); @@ -134,21 +128,20 @@ impl Node { let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) .with_tokio() .with_tcp( - tcp::Config::default(), + Self::default_tcp_config(), noise::Config::new, yamux::Config::default, ) - .unwrap() - .with_behaviour(behavior_fn) - .unwrap() - .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) + .map_err(P2PError::failed_to_build_swarm)? + .with_dns() + .map_err(P2PError::failed_to_build_swarm)? + .with_relay_client(noise::Config::new, yamux::Config::default) + .map_err(P2PError::failed_to_build_swarm)? + .with_behaviour(behaviour_fn) + .map_err(P2PError::failed_to_build_swarm)? + .with_swarm_config(Self::default_swarm_config) .build(); - Node { - swarm, - callbacks: vec![], - } + Ok(Node { swarm }) } - - pub fn add_callback(&mut self, callback: Box) + Send + Sync>) {} } From 30a550b341b335d6e43e27b86d1f5d19710e0bc4 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:01:29 +0200 Subject: [PATCH 10/24] fix: linter warnings --- crates/charon-p2p/examples/p2p.rs | 6 ++- .../charon-p2p/src/behaviours/pluto_mdns.rs | 2 +- crates/charon-p2p/src/p2p.rs | 50 ++++++++++--------- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs index 2b3b3915..0153f2f8 100644 --- a/crates/charon-p2p/examples/p2p.rs +++ b/crates/charon-p2p/examples/p2p.rs @@ -7,8 +7,10 @@ use anyhow::Result; use charon_eth2::enr::Record; use charon_p2p::{ - behaviours::pluto::{PlutoBehaviourEvent}, - behaviours::pluto_mdns::{PlutoMdnsBehaviour, PlutoMdnsBehaviourEvent}, + behaviours::{ + pluto::PlutoBehaviourEvent, + pluto_mdns::{PlutoMdnsBehaviour, PlutoMdnsBehaviourEvent}, + }, config::P2PConfig, gater::ConnGater, p2p::{Node, NodeType}, diff --git a/crates/charon-p2p/src/behaviours/pluto_mdns.rs b/crates/charon-p2p/src/behaviours/pluto_mdns.rs index f52a9559..27adf186 100644 --- a/crates/charon-p2p/src/behaviours/pluto_mdns.rs +++ b/crates/charon-p2p/src/behaviours/pluto_mdns.rs @@ -18,7 +18,7 @@ impl PlutoMdnsBehaviour { Self { pluto: PlutoBehaviour::new(key, relay_client), mdns: mdns::tokio::Behaviour::new(mdns::Config::default(), key.public().to_peer_id()) - .unwrap(), + .expect("Failed to create mDNS behaviour"), } } } diff --git a/crates/charon-p2p/src/p2p.rs b/crates/charon-p2p/src/p2p.rs index b9e3a22b..ca62542a 100644 --- a/crates/charon-p2p/src/p2p.rs +++ b/crates/charon-p2p/src/p2p.rs @@ -1,37 +1,31 @@ -#![allow(missing_docs)] -#![allow(dead_code)] -#![allow(unused)] - //! P2P core concepts -use std::{sync::Once, time::Duration}; +use std::time::Duration; use libp2p::{ - Swarm, SwarmBuilder, identify, - identity::Keypair, - noise, ping, relay, - swarm::{NetworkBehaviour, SwarmEvent}, - tcp, yamux, + Swarm, SwarmBuilder, identity::Keypair, noise, relay, swarm::NetworkBehaviour, tcp, yamux, }; -use libp2p::mdns; - use crate::{config::P2PConfig, gater::ConnGater}; +/// P2P error. #[derive(Debug, thiserror::Error)] pub enum P2PError { /// Failed to build the swarm. #[error("Failed to build the swarm: {0}")] FailedToBuildSwarm(Box), + /// Failed to convert the secret key to a libp2p keypair. #[error("Failed to convert the secret key to a libp2p keypair: {0}")] FailedToConvertSecretKeyToLibp2pKeypair(#[from] k256::pkcs8::der::Error), + /// Failed to decode the libp2p keypair. #[error("Failed to decode the libp2p keypair: {0}")] FailedToDecodeLibp2pKeypair(#[from] libp2p::identity::DecodingError), } impl P2PError { + /// Failed to build the swarm. pub fn failed_to_build_swarm(error: impl std::error::Error + Send + Sync + 'static) -> Self { Self::FailedToBuildSwarm(Box::new(error)) } @@ -39,16 +33,22 @@ impl P2PError { type Result = std::result::Result; +/// Node type. pub enum NodeType { + /// TCP node. TCP, + /// QUIC node. QUIC, } +/// Node. pub struct Node { + /// Swarm. pub swarm: Swarm, } impl Node { + /// Creates a new node. pub fn new( cfg: P2PConfig, key: k256::SecretKey, @@ -78,11 +78,12 @@ impl Node { tcp::Config::default() } - pub fn new_with_quic( - cfg: P2PConfig, + /// Creates a new node with QUIC. + fn new_with_quic( + _cfg: P2PConfig, key: k256::SecretKey, - conn_gater: ConnGater, - filter_private_addrs: bool, + _conn_gater: ConnGater, + _filter_private_addrs: bool, behaviour_fn: F, ) -> Result where @@ -91,7 +92,7 @@ impl Node { let mut der = key.to_sec1_der()?; let keypair = Keypair::secp256k1_from_der(&mut der)?; - let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) + let swarm = SwarmBuilder::with_existing_identity(keypair.clone()) .with_tokio() .with_tcp( Self::default_tcp_config(), @@ -112,20 +113,21 @@ impl Node { Ok(Node { swarm }) } - pub fn new_with_tcp( - cfg: P2PConfig, + /// Creates a new node with TCP. + fn new_with_tcp( + _cfg: P2PConfig, key: k256::SecretKey, - conn_gater: ConnGater, - filter_private_addrs: bool, + _conn_gater: ConnGater, + _filter_private_addrs: bool, behaviour_fn: F, ) -> Result where F: Fn(&Keypair, relay::client::Behaviour) -> B, { - let mut der = key.to_sec1_der().unwrap(); - let keypair = Keypair::secp256k1_from_der(&mut der).unwrap(); + let mut der = key.to_sec1_der()?; + let keypair = Keypair::secp256k1_from_der(&mut der)?; - let mut swarm = SwarmBuilder::with_existing_identity(keypair.clone()) + let swarm = SwarmBuilder::with_existing_identity(keypair.clone()) .with_tokio() .with_tcp( Self::default_tcp_config(), From ddbb57b583a999de54bdcfe79b99f2fc0c4df800 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:14:45 +0200 Subject: [PATCH 11/24] fix: remove wildcard dependencies --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 271337c3..c42c4816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ license = "Apache-2.0" # TODO(template) updat publish = false [workspace.dependencies] -anyhow = "*" # for testing purposes only +anyhow = "1" alloy = { version = "1.1.3", features = ["essentials"] } axum = "0.8.6" blst = "0.3.13" From 3c5d3feda46cf628be193ab0ac35a212b6a23bfb Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:32:58 +0200 Subject: [PATCH 12/24] feat: add connection gater --- crates/charon-p2p/examples/p2p.rs | 2 +- crates/charon-p2p/src/behaviours/pluto.rs | 5 + crates/charon-p2p/src/gater.rs | 4 - crates/charon-p2p/src/gater/handler.rs | 104 ++++++++++++ crates/charon-p2p/src/gater/mod.rs | 198 ++++++++++++++++++++++ 5 files changed, 308 insertions(+), 5 deletions(-) delete mode 100644 crates/charon-p2p/src/gater.rs create mode 100644 crates/charon-p2p/src/gater/handler.rs create mode 100644 crates/charon-p2p/src/gater/mod.rs diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs index 0153f2f8..c262b38e 100644 --- a/crates/charon-p2p/examples/p2p.rs +++ b/crates/charon-p2p/examples/p2p.rs @@ -40,7 +40,7 @@ async fn main() -> Result<()> { let mut p2p: Node<_> = Node::new( P2PConfig::default(), key.clone(), - ConnGater, + ConnGater::new_open_gater(), false, NodeType::QUIC, PlutoMdnsBehaviour::new, diff --git a/crates/charon-p2p/src/behaviours/pluto.rs b/crates/charon-p2p/src/behaviours/pluto.rs index 63a593b3..31b8e915 100644 --- a/crates/charon-p2p/src/behaviours/pluto.rs +++ b/crates/charon-p2p/src/behaviours/pluto.rs @@ -4,8 +4,12 @@ use std::time::Duration; use libp2p::{identify, identity::Keypair, ping, relay, swarm::NetworkBehaviour}; +use crate::gater::ConnGater; + #[derive(NetworkBehaviour)] pub struct PlutoBehaviour { + /// Connection gater behaviour. + pub gater: ConnGater, /// Relay client behaviour. pub relay: relay::client::Behaviour, /// Identify behaviour. @@ -28,6 +32,7 @@ impl PlutoBehaviour { .with_interval(Duration::from_secs(1)) .with_timeout(Duration::from_secs(2)), ), + gater: ConnGater::new_conn_gater(vec![], vec![]), } } } diff --git a/crates/charon-p2p/src/gater.rs b/crates/charon-p2p/src/gater.rs deleted file mode 100644 index e56aec61..00000000 --- a/crates/charon-p2p/src/gater.rs +++ /dev/null @@ -1,4 +0,0 @@ -//! Gater is responsible for whitelisting / blacklisting peers - -/// todo: Temporary empty -pub struct ConnGater; diff --git a/crates/charon-p2p/src/gater/handler.rs b/crates/charon-p2p/src/gater/handler.rs new file mode 100644 index 00000000..a343e2dc --- /dev/null +++ b/crates/charon-p2p/src/gater/handler.rs @@ -0,0 +1,104 @@ +//! Connection handler for the gater behaviour. +//! +//! This is a dummy handler since the gater doesn't need to negotiate any protocols +//! or handle any connection-level events. The actual gating logic happens at the +//! connection establishment phase in the `NetworkBehaviour` implementation. + +use std::{ + convert::Infallible, + task::{Context, Poll}, +}; + +use libp2p::swarm::{ + ConnectionHandler, ConnectionHandlerEvent, Stream, SubstreamProtocol, + handler::ConnectionEvent, +}; + +/// Dummy connection handler for the gater. +/// +/// This handler doesn't negotiate any protocols or handle any events. +/// It exists only to satisfy the `NetworkBehaviour` trait requirements. +#[derive(Debug, Clone, Default)] +pub struct Handler { + _private: (), +} + +impl Handler { + /// Creates a new handler. + pub fn new() -> Self { + Self { _private: () } + } +} + +impl ConnectionHandler for Handler { + type FromBehaviour = Infallible; + type ToBehaviour = Infallible; + type InboundProtocol = DeniedUpgrade; + type OutboundProtocol = DeniedUpgrade; + type InboundOpenInfo = (); + type OutboundOpenInfo = Infallible; + + fn listen_protocol(&self) -> SubstreamProtocol { + SubstreamProtocol::new(DeniedUpgrade, ()) + } + + fn poll( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll< + ConnectionHandlerEvent, + > { + Poll::Pending + } + + fn on_behaviour_event(&mut self, event: Self::FromBehaviour) { + match event {} + } + + fn on_connection_event( + &mut self, + _event: ConnectionEvent< + Self::InboundProtocol, + Self::OutboundProtocol, + Self::InboundOpenInfo, + Self::OutboundOpenInfo, + >, + ) { + // No events to handle + } +} + +/// A protocol upgrade that always denies the upgrade. +/// +/// This is used because the gater doesn't need to negotiate any protocols. +#[derive(Debug, Clone, Copy, Default)] +pub struct DeniedUpgrade; + +impl libp2p::core::UpgradeInfo for DeniedUpgrade { + type Info = &'static str; + type InfoIter = std::iter::Empty; + + fn protocol_info(&self) -> Self::InfoIter { + std::iter::empty() + } +} + +impl libp2p::core::upgrade::InboundUpgrade for DeniedUpgrade { + type Output = Infallible; + type Error = Infallible; + type Future = std::future::Pending>; + + fn upgrade_inbound(self, _: Stream, _: Self::Info) -> Self::Future { + std::future::pending() + } +} + +impl libp2p::core::upgrade::OutboundUpgrade for DeniedUpgrade { + type Output = Infallible; + type Error = Infallible; + type Future = std::future::Pending>; + + fn upgrade_outbound(self, _: Stream, _: Self::Info) -> Self::Future { + std::future::pending() + } +} diff --git a/crates/charon-p2p/src/gater/mod.rs b/crates/charon-p2p/src/gater/mod.rs new file mode 100644 index 00000000..1caeec4f --- /dev/null +++ b/crates/charon-p2p/src/gater/mod.rs @@ -0,0 +1,198 @@ +//! Gater is responsible for whitelisting / blacklisting peers. +//! +//! This module provides connection gating functionality that limits access to +//! cluster peers and relays. In Rust libp2p, connection gating is implemented +//! via the `NetworkBehaviour` trait, specifically through the +//! `handle_established_inbound_connection` and `handle_established_outbound_connection` +//! methods which can reject connections by returning `ConnectionDenied`. + +use std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + task::{Context, Poll}, +}; + +use libp2p::{ + Multiaddr, PeerId, + swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, + }, +}; + +use crate::peer::MutablePeer; + +mod handler; + +/// Configuration for the connection gater. +#[derive(Clone, Default)] +pub struct Config { + peer_ids: HashSet, + relays: Vec>, + open: bool, +} + +impl Config { + /// Creates a new open gater configuration that does not gate any connections. + pub fn open() -> Self { + Self { + peer_ids: HashSet::new(), + relays: Vec::new(), + open: true, + } + } + + /// Creates a new closed gater configuration that gates all connections + /// except those explicitly allowed. + pub fn closed() -> Self { + Self { + peer_ids: HashSet::new(), + relays: Vec::new(), + open: false, + } + } + + /// Sets the allowed peer IDs. + pub fn with_peer_ids(mut self, peer_ids: Vec) -> Self { + self.peer_ids = peer_ids.into_iter().collect(); + self + } + + /// Sets the relay peers. + pub fn with_relays(mut self, relays: Vec>) -> Self { + self.relays = relays; + self + } +} + +/// ConnGater filters incoming and outgoing connections by the cluster peers. +#[derive(Clone, Default)] +pub struct ConnGater { + config: Config, + events: VecDeque, +} + +impl ConnGater { + /// Creates a new connection gater with the given configuration. + pub fn new(config: Config) -> Self { + Self { config, events: VecDeque::new() } + } + + /// Creates a new connection gater that limits access to the cluster peers and relays. + pub fn new_conn_gater(peers: Vec, relays: Vec>) -> Self { + Self { + config: Config::closed().with_peer_ids(peers).with_relays(relays), + events: VecDeque::new(), + } + } + + /// Creates a new open gater that does not gate any connections. + pub fn new_open_gater() -> Self { + Self { + config: Config::open(), + events: VecDeque::new(), + } + } + + /// Returns true if the gater is open (not gating any connections). + pub fn is_open(&self) -> bool { + self.config.open + } + + /// Checks if a peer is allowed to connect. + fn is_peer_allowed(&self, peer_id: &PeerId) -> bool { + if self.config.open { + return true; + } + + // Check if peer is in the allowed set + if self.config.peer_ids.contains(peer_id) { + return true; + } + + // Check if peer is a relay + for relay in &self.config.relays { + if let Ok(Some(peer)) = relay.peer() { + if peer.id == *peer_id { + return true; + } + } + } + + false + } +} + +/// Event emitted by the connection gater behaviour. +#[derive(Debug, Clone)] +pub enum Event { + /// A peer was blocked from connecting. + PeerBlocked(PeerId), +} + +impl NetworkBehaviour for ConnGater { + type ConnectionHandler = handler::Handler; + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + _connection_id: ConnectionId, + peer: PeerId, + _local_addr: &Multiaddr, + _remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + if self.is_peer_allowed(&peer) { + Ok(handler::Handler::new()) + } else { + self.events.push_back(Event::PeerBlocked(peer)); + Err(ConnectionDenied::new(PeerNotAllowed(peer))) + } + } + + fn handle_established_outbound_connection( + &mut self, + _connection_id: ConnectionId, + _peer: PeerId, + _addr: &Multiaddr, + _role_override: libp2p::core::Endpoint, + _port_use: libp2p::core::transport::PortUse, + ) -> Result, ConnectionDenied> { + // Allow all outbound connections + Ok(handler::Handler::new()) + } + + fn on_swarm_event(&mut self, _event: FromSwarm) { + // No special handling needed for swarm events + } + + fn on_connection_handler_event( + &mut self, + _peer_id: PeerId, + _connection_id: ConnectionId, + _event: THandlerOutEvent, + ) { + // Handler events are Void, so this is unreachable + } + + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll>> { + // Emit any blocked events + if !self.events.is_empty() { + let event = self.events.pop_front().expect("events is not empty"); + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } +} + +/// Error indicating a peer is not allowed to connect. +#[derive(Debug, Clone)] +pub struct PeerNotAllowed(pub PeerId); + +impl std::fmt::Display for PeerNotAllowed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "peer {} is not in the allowed list", self.0) + } +} + +impl std::error::Error for PeerNotAllowed {} From c663f6156ca947c3acbed05e288752789a8c3912 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:37:27 +0200 Subject: [PATCH 13/24] fix: linter warnings --- crates/charon-p2p/src/gater/handler.rs | 20 +++++++++---------- crates/charon-p2p/src/gater/mod.rs | 27 ++++++++++++++++---------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/charon-p2p/src/gater/handler.rs b/crates/charon-p2p/src/gater/handler.rs index a343e2dc..a975cfb9 100644 --- a/crates/charon-p2p/src/gater/handler.rs +++ b/crates/charon-p2p/src/gater/handler.rs @@ -1,8 +1,9 @@ //! Connection handler for the gater behaviour. //! -//! This is a dummy handler since the gater doesn't need to negotiate any protocols -//! or handle any connection-level events. The actual gating logic happens at the -//! connection establishment phase in the `NetworkBehaviour` implementation. +//! This is a dummy handler since the gater doesn't need to negotiate any +//! protocols or handle any connection-level events. The actual gating logic +//! happens at the connection establishment phase in the `NetworkBehaviour` +//! implementation. use std::{ convert::Infallible, @@ -10,8 +11,7 @@ use std::{ }; use libp2p::swarm::{ - ConnectionHandler, ConnectionHandlerEvent, Stream, SubstreamProtocol, - handler::ConnectionEvent, + ConnectionHandler, ConnectionHandlerEvent, Stream, SubstreamProtocol, handler::ConnectionEvent, }; /// Dummy connection handler for the gater. @@ -32,11 +32,11 @@ impl Handler { impl ConnectionHandler for Handler { type FromBehaviour = Infallible; - type ToBehaviour = Infallible; - type InboundProtocol = DeniedUpgrade; - type OutboundProtocol = DeniedUpgrade; type InboundOpenInfo = (); + type InboundProtocol = DeniedUpgrade; type OutboundOpenInfo = Infallible; + type OutboundProtocol = DeniedUpgrade; + type ToBehaviour = Infallible; fn listen_protocol(&self) -> SubstreamProtocol { SubstreamProtocol::new(DeniedUpgrade, ()) @@ -84,9 +84,9 @@ impl libp2p::core::UpgradeInfo for DeniedUpgrade { } impl libp2p::core::upgrade::InboundUpgrade for DeniedUpgrade { - type Output = Infallible; type Error = Infallible; type Future = std::future::Pending>; + type Output = Infallible; fn upgrade_inbound(self, _: Stream, _: Self::Info) -> Self::Future { std::future::pending() @@ -94,9 +94,9 @@ impl libp2p::core::upgrade::InboundUpgrade for DeniedUpgrade { } impl libp2p::core::upgrade::OutboundUpgrade for DeniedUpgrade { - type Output = Infallible; type Error = Infallible; type Future = std::future::Pending>; + type Output = Infallible; fn upgrade_outbound(self, _: Stream, _: Self::Info) -> Self::Future { std::future::pending() diff --git a/crates/charon-p2p/src/gater/mod.rs b/crates/charon-p2p/src/gater/mod.rs index 1caeec4f..8d98be49 100644 --- a/crates/charon-p2p/src/gater/mod.rs +++ b/crates/charon-p2p/src/gater/mod.rs @@ -3,8 +3,9 @@ //! This module provides connection gating functionality that limits access to //! cluster peers and relays. In Rust libp2p, connection gating is implemented //! via the `NetworkBehaviour` trait, specifically through the -//! `handle_established_inbound_connection` and `handle_established_outbound_connection` -//! methods which can reject connections by returning `ConnectionDenied`. +//! `handle_established_inbound_connection` and +//! `handle_established_outbound_connection` methods which can reject +//! connections by returning `ConnectionDenied`. use std::{ collections::{HashSet, VecDeque}, @@ -33,7 +34,8 @@ pub struct Config { } impl Config { - /// Creates a new open gater configuration that does not gate any connections. + /// Creates a new open gater configuration that does not gate any + /// connections. pub fn open() -> Self { Self { peer_ids: HashSet::new(), @@ -75,10 +77,14 @@ pub struct ConnGater { impl ConnGater { /// Creates a new connection gater with the given configuration. pub fn new(config: Config) -> Self { - Self { config, events: VecDeque::new() } + Self { + config, + events: VecDeque::new(), + } } - /// Creates a new connection gater that limits access to the cluster peers and relays. + /// Creates a new connection gater that limits access to the cluster peers + /// and relays. pub fn new_conn_gater(peers: Vec, relays: Vec>) -> Self { Self { config: Config::closed().with_peer_ids(peers).with_relays(relays), @@ -112,10 +118,8 @@ impl ConnGater { // Check if peer is a relay for relay in &self.config.relays { - if let Ok(Some(peer)) = relay.peer() { - if peer.id == *peer_id { - return true; - } + if let Ok(Some(peer)) = relay.peer() && peer.id == *peer_id { + return true; } } @@ -174,7 +178,10 @@ impl NetworkBehaviour for ConnGater { // Handler events are Void, so this is unreachable } - fn poll(&mut self, _cx: &mut Context<'_>) -> Poll>> { + fn poll( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll>> { // Emit any blocked events if !self.events.is_empty() { let event = self.events.pop_front().expect("events is not empty"); From a2440633e1c57e7ec3089a75575701e0e2c24f90 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:40:22 +0200 Subject: [PATCH 14/24] fix: linter warnings --- crates/charon-p2p/src/gater/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/charon-p2p/src/gater/mod.rs b/crates/charon-p2p/src/gater/mod.rs index 8d98be49..1c59345e 100644 --- a/crates/charon-p2p/src/gater/mod.rs +++ b/crates/charon-p2p/src/gater/mod.rs @@ -118,7 +118,9 @@ impl ConnGater { // Check if peer is a relay for relay in &self.config.relays { - if let Ok(Some(peer)) = relay.peer() && peer.id == *peer_id { + if let Ok(Some(peer)) = relay.peer() + && peer.id == *peer_id + { return true; } } From adfc1ef4f42f2733a4f3ba50065e0c407b538b77 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:48:12 +0200 Subject: [PATCH 15/24] feat: create behaviour builder --- crates/charon-p2p/src/behaviours/pluto.rs | 75 ++++++++++++++++-- .../charon-p2p/src/behaviours/pluto_mdns.rs | 76 +++++++++++++++++-- 2 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/charon-p2p/src/behaviours/pluto.rs b/crates/charon-p2p/src/behaviours/pluto.rs index 31b8e915..ad463e8c 100644 --- a/crates/charon-p2p/src/behaviours/pluto.rs +++ b/crates/charon-p2p/src/behaviours/pluto.rs @@ -6,6 +6,7 @@ use libp2p::{identify, identity::Keypair, ping, relay, swarm::NetworkBehaviour}; use crate::gater::ConnGater; +/// Pluto network behaviour. #[derive(NetworkBehaviour)] pub struct PlutoBehaviour { /// Connection gater behaviour. @@ -19,20 +20,84 @@ pub struct PlutoBehaviour { } impl PlutoBehaviour { - /// Creates a new Pluto behaviour. + /// Creates a new Pluto behaviour with default configuration. pub fn new(key: &Keypair, relay_client: relay::client::Behaviour) -> Self { + PlutoBehaviourBuilder::default().build(key, relay_client) + } + + /// Returns a new builder for configuring a PlutoBehaviour. + pub fn builder() -> PlutoBehaviourBuilder { + PlutoBehaviourBuilder::default() + } +} + +/// Builder for [`PlutoBehaviour`]. +#[derive(Clone)] +pub struct PlutoBehaviourBuilder { + gater: Option, + identify_protocol: String, + ping_interval: Duration, + ping_timeout: Duration, +} + +impl Default for PlutoBehaviourBuilder { + fn default() -> Self { Self { + gater: None, + identify_protocol: "/pluto/1.0.0-alpha".into(), + ping_interval: Duration::from_secs(1), + ping_timeout: Duration::from_secs(2), + } + } +} + +impl PlutoBehaviourBuilder { + /// Creates a new builder with default configuration. + pub fn new() -> Self { + Self::default() + } + + /// Sets the connection gater. + pub fn with_gater(mut self, gater: ConnGater) -> Self { + self.gater = Some(gater); + self + } + + /// Sets the identify protocol string. + pub fn with_identify_protocol(mut self, protocol: impl Into) -> Self { + self.identify_protocol = protocol.into(); + self + } + + /// Sets the ping interval. + pub fn with_ping_interval(mut self, interval: Duration) -> Self { + self.ping_interval = interval; + self + } + + /// Sets the ping timeout. + pub fn with_ping_timeout(mut self, timeout: Duration) -> Self { + self.ping_timeout = timeout; + self + } + + /// Builds the [`PlutoBehaviour`] with the provided keypair and relay + /// client. + pub fn build(self, key: &Keypair, relay_client: relay::client::Behaviour) -> PlutoBehaviour { + PlutoBehaviour { + gater: self + .gater + .unwrap_or_else(|| ConnGater::new_conn_gater(vec![], vec![])), relay: relay_client, identify: identify::Behaviour::new(identify::Config::new( - "/pluto/1.0.0-alpha".into(), + self.identify_protocol, key.public(), )), ping: ping::Behaviour::new( ping::Config::new() - .with_interval(Duration::from_secs(1)) - .with_timeout(Duration::from_secs(2)), + .with_interval(self.ping_interval) + .with_timeout(self.ping_timeout), ), - gater: ConnGater::new_conn_gater(vec![], vec![]), } } } diff --git a/crates/charon-p2p/src/behaviours/pluto_mdns.rs b/crates/charon-p2p/src/behaviours/pluto_mdns.rs index 27adf186..f11b4727 100644 --- a/crates/charon-p2p/src/behaviours/pluto_mdns.rs +++ b/crates/charon-p2p/src/behaviours/pluto_mdns.rs @@ -1,9 +1,10 @@ //! Pluto Mdns behaviour. + use libp2p::{identity::Keypair, mdns, relay, swarm::NetworkBehaviour}; -use crate::behaviours::pluto::PlutoBehaviour; +use crate::behaviours::pluto::{PlutoBehaviour, PlutoBehaviourBuilder}; -/// Pluto network behaviour. +/// Pluto network behaviour with mDNS discovery. #[derive(NetworkBehaviour)] pub struct PlutoMdnsBehaviour { /// Pluto behaviour. @@ -13,11 +14,76 @@ pub struct PlutoMdnsBehaviour { } impl PlutoMdnsBehaviour { - /// Creates a new Pluto Mdns behaviour. + /// Creates a new Pluto Mdns behaviour with default configuration. pub fn new(key: &Keypair, relay_client: relay::client::Behaviour) -> Self { + PlutoMdnsBehaviourBuilder::default().build(key, relay_client) + } + + /// Returns a new builder for configuring a PlutoMdnsBehaviour. + pub fn builder() -> PlutoMdnsBehaviourBuilder { + PlutoMdnsBehaviourBuilder::default() + } +} + +/// Builder for [`PlutoMdnsBehaviour`]. +pub struct PlutoMdnsBehaviourBuilder { + pluto: PlutoBehaviourBuilder, + mdns_config: mdns::Config, +} + +impl Default for PlutoMdnsBehaviourBuilder { + fn default() -> Self { Self { - pluto: PlutoBehaviour::new(key, relay_client), - mdns: mdns::tokio::Behaviour::new(mdns::Config::default(), key.public().to_peer_id()) + pluto: PlutoBehaviourBuilder::default(), + mdns_config: mdns::Config::default(), + } + } +} + +impl PlutoMdnsBehaviourBuilder { + /// Creates a new builder with default configuration. + pub fn new() -> Self { + Self::default() + } + + /// Replaces the inner [`PlutoBehaviourBuilder`] entirely. + pub fn with_pluto(mut self, pluto: PlutoBehaviourBuilder) -> Self { + self.pluto = pluto; + self + } + + /// Configures the inner [`PlutoBehaviourBuilder`] via a closure. + /// + /// This is ergonomic for inline configuration: + /// ```ignore + /// PlutoMdnsBehaviourBuilder::new() + /// .configure_pluto(|p| p.with_ping_interval(Duration::from_secs(5))) + /// .build(&key, relay_client) + /// ``` + pub fn configure_pluto( + mut self, + f: impl FnOnce(PlutoBehaviourBuilder) -> PlutoBehaviourBuilder, + ) -> Self { + self.pluto = f(self.pluto); + self + } + + /// Sets the mDNS configuration. + pub fn with_mdns_config(mut self, config: mdns::Config) -> Self { + self.mdns_config = config; + self + } + + /// Builds the [`PlutoMdnsBehaviour`] with the provided keypair and relay + /// client. + pub fn build( + self, + key: &Keypair, + relay_client: relay::client::Behaviour, + ) -> PlutoMdnsBehaviour { + PlutoMdnsBehaviour { + pluto: self.pluto.build(key, relay_client), + mdns: mdns::tokio::Behaviour::new(self.mdns_config, key.public().to_peer_id()) .expect("Failed to create mDNS behaviour"), } } From 31c4e27a36c13a9259a5cf0e847de354ac58b920 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:46:09 +0200 Subject: [PATCH 16/24] feat: add relay server functionality --- Cargo.lock | 6 + Cargo.toml | 2 + crates/charon-p2p/Cargo.toml | 6 + crates/charon-p2p/examples/p2p.rs | 1 - crates/charon-p2p/examples/relay_server.rs | 49 +++ crates/charon-p2p/src/behaviours/mod.rs | 3 + .../charon-p2p/src/behaviours/relay_server.rs | 112 ++++++ crates/charon-p2p/src/config.rs | 65 +++- crates/charon-p2p/src/lib.rs | 3 + crates/charon-p2p/src/metrics.rs | 3 +- crates/charon-p2p/src/p2p.rs | 52 ++- crates/charon-p2p/src/relay/config.rs | 178 ++++++++++ crates/charon-p2p/src/relay/error.rs | 23 ++ crates/charon-p2p/src/relay/metrics.rs | 32 ++ crates/charon-p2p/src/relay/mod.rs | 22 ++ crates/charon-p2p/src/relay/p2p.rs | 122 +++++++ crates/charon-p2p/src/relay/utils.rs | 71 ++++ crates/charon-p2p/src/relay/web.rs | 334 ++++++++++++++++++ crates/tracing/src/lib.rs | 2 + 19 files changed, 1072 insertions(+), 14 deletions(-) create mode 100644 crates/charon-p2p/examples/relay_server.rs create mode 100644 crates/charon-p2p/src/behaviours/relay_server.rs create mode 100644 crates/charon-p2p/src/relay/config.rs create mode 100644 crates/charon-p2p/src/relay/error.rs create mode 100644 crates/charon-p2p/src/relay/metrics.rs create mode 100644 crates/charon-p2p/src/relay/mod.rs create mode 100644 crates/charon-p2p/src/relay/p2p.rs create mode 100644 crates/charon-p2p/src/relay/utils.rs create mode 100644 crates/charon-p2p/src/relay/web.rs diff --git a/Cargo.lock b/Cargo.lock index 77becbba..1a6fe28d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1538,17 +1538,23 @@ name = "charon-p2p" version = "0.1.0" dependencies = [ "anyhow", + "axum", "charon-eth2", "charon-k1util", "charon-testutil", + "charon-tracing", "chrono", "clap", "k256", "libp2p", "rand 0.8.5", + "serde", + "serde_json", "tempfile", "thiserror 2.0.17", "tokio", + "tokio-util", + "tracing", "vise", "vise-exporter", ] diff --git a/Cargo.toml b/Cargo.toml index c42c4816..cab7219c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "^1.0" } thiserror = "2.0.12" tokio = { version = "1", features = ["full"] } +tokio-util = "0.7.11" libp2p = { version = "0.56", features = ["full", "secp256k1"] } url = { version = "2.5.7" } uuid = { version = "1.16", features = ["serde", "v4"] } @@ -63,6 +64,7 @@ charon-eth2 = { path = "crates/charon-eth2" } charon-k1util = { path = "crates/charon-k1util" } charon-p2p = { path = "crates/charon-p2p" } charon-testutil = { path = "crates/charon-testutil" } +charon-tracing = { path = "crates/tracing" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/charon-p2p/Cargo.toml b/crates/charon-p2p/Cargo.toml index aa93703e..b1b2365e 100644 --- a/crates/charon-p2p/Cargo.toml +++ b/crates/charon-p2p/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true publish.workspace = true [dependencies] +axum.workspace = true chrono.workspace = true libp2p.workspace = true thiserror.workspace = true @@ -15,8 +16,13 @@ charon-eth2.workspace = true charon-k1util.workspace = true vise.workspace = true tokio.workspace = true +tokio-util.workspace = true rand.workspace = true tempfile.workspace = true +charon-tracing.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true [dev-dependencies] charon-testutil.workspace = true diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs index c262b38e..e4d9a77b 100644 --- a/crates/charon-p2p/examples/p2p.rs +++ b/crates/charon-p2p/examples/p2p.rs @@ -40,7 +40,6 @@ async fn main() -> Result<()> { let mut p2p: Node<_> = Node::new( P2PConfig::default(), key.clone(), - ConnGater::new_open_gater(), false, NodeType::QUIC, PlutoMdnsBehaviour::new, diff --git a/crates/charon-p2p/examples/relay_server.rs b/crates/charon-p2p/examples/relay_server.rs new file mode 100644 index 00000000..c750dccb --- /dev/null +++ b/crates/charon-p2p/examples/relay_server.rs @@ -0,0 +1,49 @@ +#![allow(missing_docs)] +use std::str::FromStr; + +use charon_p2p::{ + config::P2PConfig, + relay::{config::Config, p2p::run_relay_p2p_node}, +}; +use charon_tracing::TracingConfig; +use k256::SecretKey; +use libp2p::multiaddr; +use rand::rngs::OsRng; +use tokio_util::sync::CancellationToken; +use tracing::info; + +#[tokio::main] +async fn main() { + charon_tracing::init(&TracingConfig::default()).unwrap(); + + let config = Config::builder() + .with_p2p_config( + P2PConfig::builder() + .with_tcp_addrs(vec![ + multiaddr::Multiaddr::from_str("/ip4/0.0.0.0/tcp/0") + .unwrap() + .to_string(), + ]) + .build(), + ) + .with_max_conns(100) + .with_max_res_per_peer(10) + .with_http_addr(Some("0.0.0.0:8888".to_string())) + .build(); + let key = SecretKey::random(&mut OsRng); + + let ct = CancellationToken::new(); + + tokio::select! { + result = run_relay_p2p_node(&config, key, ct.child_token()) => { + result.unwrap(); + } + _ = tokio::signal::ctrl_c() => { + info!("Shutdown signal received, shutting down gracefully..."); + ct.cancel(); + + } + } + + info!("Shutdown complete"); +} diff --git a/crates/charon-p2p/src/behaviours/mod.rs b/crates/charon-p2p/src/behaviours/mod.rs index 5a16f8ed..272a08e8 100644 --- a/crates/charon-p2p/src/behaviours/mod.rs +++ b/crates/charon-p2p/src/behaviours/mod.rs @@ -7,3 +7,6 @@ pub mod pluto; #[cfg(feature = "mdns")] /// Pluto Mdns behaviour. pub mod pluto_mdns; + +/// Relay server behaviour. +pub mod relay_server; diff --git a/crates/charon-p2p/src/behaviours/relay_server.rs b/crates/charon-p2p/src/behaviours/relay_server.rs new file mode 100644 index 00000000..baf2a2d9 --- /dev/null +++ b/crates/charon-p2p/src/behaviours/relay_server.rs @@ -0,0 +1,112 @@ +//! Relay server behaviour. + +use std::time::Duration; + +use libp2p::{identify, identity::Keypair, ping, relay, swarm::NetworkBehaviour}; + +use crate::gater::ConnGater; + +/// Relay server network behaviour. +#[derive(NetworkBehaviour)] +pub struct RelayServerBehaviour { + /// Relay server. + pub relay: relay::Behaviour, + /// Identify behaviour. + pub identify: identify::Behaviour, + /// Ping behaviour. + pub ping: ping::Behaviour, + /// Gater behaviour. + pub gater: ConnGater, +} + +impl RelayServerBehaviour { + /// Creates a new RelayServerBehaviour with default configuration. + pub fn new(key: &Keypair) -> Self { + RelayServerBehaviourBuilder::default().build(key) + } + + /// Returns a new builder for configuring a RelayServerBehaviour. + pub fn builder() -> RelayServerBehaviourBuilder { + RelayServerBehaviourBuilder::default() + } +} + +/// Builder for [`RelayServerBehaviour`]. +pub struct RelayServerBehaviourBuilder { + gater: Option, + identify_protocol: String, + ping_interval: Duration, + ping_timeout: Duration, + relay_config: Option, +} + +impl Default for RelayServerBehaviourBuilder { + fn default() -> Self { + Self { + gater: None, + identify_protocol: "/pluto/relay/1.0.0-alpha".into(), + ping_interval: Duration::from_secs(1), + ping_timeout: Duration::from_secs(2), + relay_config: None, + } + } +} + +impl RelayServerBehaviourBuilder { + /// Creates a new builder with default configuration. + pub fn new() -> Self { + Self::default() + } + + /// Sets the connection gater. + pub fn with_gater(mut self, gater: ConnGater) -> Self { + self.gater = Some(gater); + self + } + + /// Sets the identify protocol string. + pub fn with_identify_protocol(mut self, protocol: impl Into) -> Self { + self.identify_protocol = protocol.into(); + self + } + + /// Sets the ping interval. + pub fn with_ping_interval(mut self, interval: Duration) -> Self { + self.ping_interval = interval; + self + } + + /// Sets the ping timeout. + pub fn with_ping_timeout(mut self, timeout: Duration) -> Self { + self.ping_timeout = timeout; + self + } + + /// Sets the relay server configuration. + pub fn with_relay_config(mut self, config: relay::Config) -> Self { + self.relay_config = Some(config); + self + } + + /// Builds the [`RelayServerBehaviour`] with the provided keypair. + pub fn build(self, key: &Keypair) -> RelayServerBehaviour { + RelayServerBehaviour { + relay: relay::Behaviour::new( + key.public().to_peer_id(), + self.relay_config.unwrap_or_default(), + ), + identify: identify::Behaviour::new(identify::Config::new( + self.identify_protocol, + key.public(), + )), + ping: ping::Behaviour::new( + ping::Config::new() + .with_interval(self.ping_interval) + .with_timeout(self.ping_timeout), + ), + gater: self + .gater + .unwrap_or_else(|| ConnGater::new_conn_gater(vec![], vec![])), + } + } +} diff --git a/crates/charon-p2p/src/config.rs b/crates/charon-p2p/src/config.rs index 10778ef2..88dea5d4 100644 --- a/crates/charon-p2p/src/config.rs +++ b/crates/charon-p2p/src/config.rs @@ -59,10 +59,10 @@ pub struct P2PConfig { pub relays: Vec, /// The external IP address of the node. - pub external_ip: String, + pub external_ip: Option, /// The external host of the node. - pub external_host: String, + pub external_host: Option, /// The TCP addresses of the node. pub tcp_addrs: Vec, @@ -98,6 +98,67 @@ impl P2PConfig { addrs.into_iter().map(multi_addr_from_ip_tcp_port).collect() } + + /// Returns a new builder for configuring a P2P configuration. + pub fn builder() -> P2PConfigBuilder { + P2PConfigBuilder::new() + } +} + +/// Builder for [`P2PConfig`]. +#[derive(Default, Debug, Clone)] +pub struct P2PConfigBuilder { + config: P2PConfig, +} + +impl P2PConfigBuilder { + /// Creates a new builder with default configuration. + pub fn new() -> Self { + Self { + config: P2PConfig::default(), + } + } + + /// Sets the relay multiaddrs. + pub fn with_relays(mut self, relays: Vec) -> Self { + self.config.relays = relays; + self + } + + /// Sets the external IP address. + pub fn with_external_ip(mut self, external_ip: String) -> Self { + self.config.external_ip = Some(external_ip); + self + } + + /// Sets the external host. + pub fn with_external_host(mut self, external_host: String) -> Self { + self.config.external_host = Some(external_host); + self + } + + /// Sets the TCP addresses. + pub fn with_tcp_addrs(mut self, tcp_addrs: Vec) -> Self { + self.config.tcp_addrs = tcp_addrs; + self + } + + /// Sets the UDP addresses. + pub fn with_udp_addrs(mut self, udp_addrs: Vec) -> Self { + self.config.udp_addrs = udp_addrs; + self + } + + /// Sets whether to disable the reuse port. + pub fn with_disable_reuse_port(mut self, disable_reuse_port: bool) -> Self { + self.config.disable_reuse_port = disable_reuse_port; + self + } + + /// Builds the [`P2PConfig`]. + pub fn build(self) -> P2PConfig { + self.config + } } /// Resolves a TCP address string to a SocketAddr, ensuring the IP is specified. diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 2043c8e7..4aaeb1cd 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -28,3 +28,6 @@ pub mod behaviours; /// K1 utilities. pub mod k1; + +/// Everything related to relay client / server. +pub mod relay; diff --git a/crates/charon-p2p/src/metrics.rs b/crates/charon-p2p/src/metrics.rs index bfadff76..e06b3cd9 100644 --- a/crates/charon-p2p/src/metrics.rs +++ b/crates/charon-p2p/src/metrics.rs @@ -1,6 +1,7 @@ use vise::*; -const BUCKETS: [f64; 11] = [ +/// Buckets for the ping latency histogram. +pub const BUCKETS: [f64; 11] = [ 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, ]; diff --git a/crates/charon-p2p/src/p2p.rs b/crates/charon-p2p/src/p2p.rs index ca62542a..b6dc8c18 100644 --- a/crates/charon-p2p/src/p2p.rs +++ b/crates/charon-p2p/src/p2p.rs @@ -6,7 +6,7 @@ use libp2p::{ Swarm, SwarmBuilder, identity::Keypair, noise, relay, swarm::NetworkBehaviour, tcp, yamux, }; -use crate::{config::P2PConfig, gater::ConnGater}; +use crate::config::P2PConfig; /// P2P error. #[derive(Debug, thiserror::Error)] @@ -22,6 +22,14 @@ pub enum P2PError { /// Failed to decode the libp2p keypair. #[error("Failed to decode the libp2p keypair: {0}")] FailedToDecodeLibp2pKeypair(#[from] libp2p::identity::DecodingError), + + /// Failed to listen on address. + #[error("Failed to listen on address: {0}")] + FailedToListen(#[from] libp2p::TransportError), + + /// Failed to dial peer. + #[error("Failed to dial peer: {0}")] + FailedToDialPeer(#[from] libp2p::swarm::DialError), } impl P2PError { @@ -52,7 +60,6 @@ impl Node { pub fn new( cfg: P2PConfig, key: k256::SecretKey, - conn_gater: ConnGater, filter_private_addrs: bool, node_type: NodeType, behaviour_fn: F, @@ -61,12 +68,8 @@ impl Node { F: Fn(&Keypair, relay::client::Behaviour) -> B, { match node_type { - NodeType::TCP => { - Self::new_with_tcp(cfg, key, conn_gater, filter_private_addrs, behaviour_fn) - } - NodeType::QUIC => { - Self::new_with_quic(cfg, key, conn_gater, filter_private_addrs, behaviour_fn) - } + NodeType::TCP => Self::new_with_tcp(cfg, key, filter_private_addrs, behaviour_fn), + NodeType::QUIC => Self::new_with_quic(cfg, key, filter_private_addrs, behaviour_fn), } } @@ -82,7 +85,6 @@ impl Node { fn new_with_quic( _cfg: P2PConfig, key: k256::SecretKey, - _conn_gater: ConnGater, _filter_private_addrs: bool, behaviour_fn: F, ) -> Result @@ -117,7 +119,6 @@ impl Node { fn new_with_tcp( _cfg: P2PConfig, key: k256::SecretKey, - _conn_gater: ConnGater, _filter_private_addrs: bool, behaviour_fn: F, ) -> Result @@ -146,4 +147,35 @@ impl Node { Ok(Node { swarm }) } + + /// Creates a new node with relay server. + pub fn new_relay_server( + _cfg: P2PConfig, + key: k256::SecretKey, + _filter_private_addrs: bool, + behaviour_fn: F, + ) -> Result + where + F: Fn(&Keypair) -> B, + { + let mut der = key.to_sec1_der()?; + let keypair = Keypair::secp256k1_from_der(&mut der)?; + + let swarm = SwarmBuilder::with_existing_identity(keypair.clone()) + .with_tokio() + .with_tcp( + Self::default_tcp_config(), + noise::Config::new, + yamux::Config::default, + ) + .map_err(P2PError::failed_to_build_swarm)? + .with_dns() + .map_err(P2PError::failed_to_build_swarm)? + .with_behaviour(behaviour_fn) + .map_err(P2PError::failed_to_build_swarm)? + .with_swarm_config(Self::default_swarm_config) + .build(); + + Ok(Node { swarm }) + } } diff --git a/crates/charon-p2p/src/relay/config.rs b/crates/charon-p2p/src/relay/config.rs new file mode 100644 index 00000000..091d59fc --- /dev/null +++ b/crates/charon-p2p/src/relay/config.rs @@ -0,0 +1,178 @@ +use std::{path::PathBuf, time::Duration}; + +use charon_tracing::TracingConfig; +use libp2p::relay; + +use crate::config::P2PConfig; + +pub const ONE_HOUR_SECONDS: u64 = 60 * 60; +pub const ONE_MINUTE_SECONDS: u64 = 60; +pub const MB_32: u64 = 32 * 1024 * 1024; +pub const EXTERNAL_HOST_RESOLVE_INTERVAL: Duration = Duration::from_secs(5 * 60); + +// todo: make more typed +/// Configuration for the relay P2P layer. +#[derive(Default, Debug, Clone)] +pub struct Config { + /// The directory to store the relay data. + pub data_dir: PathBuf, + /// The HTTP address to listen on. + pub http_addr: Option, + /// The monitoring address to listen on. + pub monitoring_addr: String, + /// The debug address to listen on. + pub debug_addr: String, + /// The P2P configuration. + pub p2p_config: P2PConfig, + /// The logging configuration. + pub log_config: TracingConfig, + /// Whether to automatically generate a P2P key. + pub auto_p2p_key: bool, + /// The maximum number of resources per peer. + pub max_res_per_peer: usize, + /// The maximum number of connections. + pub max_conns: usize, + /// Whether to filter private addresses. + pub filter_private_addrs: bool, + /// LibP2PLogLevel. + pub libp2p_log_level: String, +} + +impl Config { + /// Creates a new configuration. + pub fn new( + data_dir: PathBuf, + http_addr: Option, + monitoring_addr: String, + debug_addr: String, + p2p_config: P2PConfig, + log_config: TracingConfig, + auto_p2p_key: bool, + max_res_per_peer: usize, + max_conns: usize, + filter_private_addrs: bool, + libp2p_log_level: String, + ) -> Self { + Self { + data_dir, + http_addr, + monitoring_addr, + debug_addr, + p2p_config, + log_config, + auto_p2p_key, + max_res_per_peer, + max_conns, + filter_private_addrs, + libp2p_log_level, + } + } + + /// Returns a new builder for configuring a relay P2P layer. + pub fn builder() -> ConfigBuilder { + ConfigBuilder::new() + } +} + +/// Builder for [`Config`]. +#[derive(Default, Debug, Clone)] +pub struct ConfigBuilder { + config: Config, +} + +impl ConfigBuilder { + /// Creates a new builder with default configuration. + pub fn new() -> Self { + Self { + config: Config::default(), + } + } + + /// Sets the data directory. + pub fn with_data_dir(mut self, data_dir: PathBuf) -> Self { + self.config.data_dir = data_dir; + self + } + + /// Sets the HTTP address. + pub fn with_http_addr(mut self, http_addr: Option) -> Self { + self.config.http_addr = http_addr; + self + } + + /// Sets the monitoring address. + pub fn with_monitoring_addr(mut self, monitoring_addr: String) -> Self { + self.config.monitoring_addr = monitoring_addr; + self + } + + /// Sets the debug address. + pub fn with_debug_addr(mut self, debug_addr: String) -> Self { + self.config.debug_addr = debug_addr; + self + } + + /// Sets the P2P configuration. + pub fn with_p2p_config(mut self, p2p_config: P2PConfig) -> Self { + self.config.p2p_config = p2p_config; + self + } + + /// Sets the logging configuration. + pub fn with_log_config(mut self, log_config: TracingConfig) -> Self { + self.config.log_config = log_config; + self + } + + /// Sets whether to automatically generate a P2P key. + pub fn with_auto_p2p_key(mut self, auto_p2p_key: bool) -> Self { + self.config.auto_p2p_key = auto_p2p_key; + self + } + + /// Sets the maximum number of resources per peer. + pub fn with_max_res_per_peer(mut self, max_res_per_peer: usize) -> Self { + self.config.max_res_per_peer = max_res_per_peer; + self + } + + /// Sets the maximum number of connections. + pub fn with_max_conns(mut self, max_conns: usize) -> Self { + self.config.max_conns = max_conns; + self + } + + /// Sets whether to filter private addresses. + pub fn with_filter_private_addrs(mut self, filter_private_addrs: bool) -> Self { + self.config.filter_private_addrs = filter_private_addrs; + self + } + + /// Sets the LibP2P log level. + pub fn with_libp2p_log_level(mut self, libp2p_log_level: String) -> Self { + self.config.libp2p_log_level = libp2p_log_level; + self + } + + /// Builds the [`Config`]. + pub fn build(self) -> Config { + self.config + } +} + +pub(crate) fn create_relay_config(config: &Config) -> relay::Config { + relay::Config { + max_reservations: config.max_conns, + max_reservations_per_peer: config.max_res_per_peer, + reservation_duration: Duration::from_secs(ONE_HOUR_SECONDS), + reservation_rate_limiters: vec![], + // todo(varex83): check if this is correct, since it's aligned with the original + // implementation, but I'm not sure if it's the correct way to do it. + // Would it be better to use max_res_per_peer * max_conns? + max_circuits: config.max_res_per_peer, + max_circuits_per_peer: config.max_res_per_peer, + max_circuit_duration: Duration::from_secs(ONE_MINUTE_SECONDS), + max_circuit_bytes: MB_32, + circuit_src_rate_limiters: vec![], + } +} diff --git a/crates/charon-p2p/src/relay/error.rs b/crates/charon-p2p/src/relay/error.rs new file mode 100644 index 00000000..94992bd4 --- /dev/null +++ b/crates/charon-p2p/src/relay/error.rs @@ -0,0 +1,23 @@ +use crate::p2p::P2PError; + +/// Relay P2P error. +#[derive(Debug, thiserror::Error)] +pub enum RelayP2PError { + /// Failed to load private key. + #[error("Failed to load private key")] + FailedToLoadPrivateKey(#[from] crate::k1::K1Error), + + /// P2P error. + #[error("P2P error: {0}")] + P2PError(#[from] P2PError), + + /// Failed to bind HTTP listener. + #[error("Failed to bind HTTP listener: {0}")] + FailedToBindHttpListener(String), + + /// Failed to serve HTTP. + #[error("Failed to serve HTTP: {0}")] + FailedToServeHTTP(std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/charon-p2p/src/relay/metrics.rs b/crates/charon-p2p/src/relay/metrics.rs new file mode 100644 index 00000000..420722cb --- /dev/null +++ b/crates/charon-p2p/src/relay/metrics.rs @@ -0,0 +1,32 @@ +use vise::*; + +use crate::metrics::BUCKETS; + +#[derive(Debug, Metrics)] +#[metrics(prefix = "relay_p2p")] +pub struct RelayMetrics { + /// Total number of new connections by peer and cluster. + connection_total: Family, + + /// Current number of active connections by peer and cluster. + active_connections: Family, + + /// Total number of network bytes sent to the peer and cluster. + network_sent_bytes_total: Family, + + /// Total number of network bytes received from the peer and cluster. + network_received_bytes_total: Family, + + /// Ping latency by peer and cluster. + #[metrics(buckets = &BUCKETS)] + ping_latency: Family, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelSet)] +pub struct PeerWithPeerClusterLabels { + peer: String, + peer_cluster: String, +} + +#[vise::register] +pub static RELAY_METRICS: Global = Global::new(); diff --git a/crates/charon-p2p/src/relay/mod.rs b/crates/charon-p2p/src/relay/mod.rs new file mode 100644 index 00000000..362dda9a --- /dev/null +++ b/crates/charon-p2p/src/relay/mod.rs @@ -0,0 +1,22 @@ +#![allow(missing_docs)] +//! Everything related to relay client / server. + +/// P2P. +pub mod p2p; + +/// Config. +pub mod config; + +/// Metrics. +pub mod metrics; + +/// Web. +pub mod web; + +/// Error. +pub mod error; + +/// Utils. +pub mod utils; + +pub use error::{RelayP2PError, Result}; diff --git a/crates/charon-p2p/src/relay/p2p.rs b/crates/charon-p2p/src/relay/p2p.rs new file mode 100644 index 00000000..1ac3564e --- /dev/null +++ b/crates/charon-p2p/src/relay/p2p.rs @@ -0,0 +1,122 @@ +#![allow(missing_docs)] + +use std::{sync::Arc, time::Duration}; + +use k256::SecretKey; +use libp2p::{futures::StreamExt, swarm::SwarmEvent}; +use tokio::sync::{RwLock, mpsc}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, instrument, warn}; + +use crate::{ + behaviours::relay_server::RelayServerBehaviour, + gater::ConnGater, + p2p::Node, + relay::{ + Result, + config::{Config, create_relay_config}, + web::enr_server, + }, +}; + +#[instrument(skip(config, key, ct))] +pub async fn run_relay_p2p_node( + config: &Config, + key: SecretKey, + ct: CancellationToken, +) -> Result> { + let mut node = Node::new_relay_server(config.p2p_config.clone(), key.clone(), false, |key| { + RelayServerBehaviour::builder() + .with_gater(ConnGater::new_open_gater()) + .with_relay_config(create_relay_config(config)) + .build(key) + })?; + + // todo: change to version::log_info + info!("Charon relay starting"); + + // todo: monitor connections + + for tcp_addr in config.p2p_config.tcp_addrs.iter() { + debug!("Listening on TCP address {}", tcp_addr); + node.swarm.listen_on(tcp_addr.parse().unwrap()).unwrap(); + } + for udp_addr in config.p2p_config.udp_addrs.iter() { + debug!("Listening on UDP address {}", udp_addr); + node.swarm.listen_on(udp_addr.parse().unwrap()).unwrap(); + } + + let (server_errors, mut server_errors_receiver) = mpsc::channel(3); + + let listeners = Arc::new(RwLock::new(Vec::new())); + + let enr_server_handle = tokio::spawn(enr_server( + server_errors.clone(), + config.clone(), + key.clone(), + *node.swarm.local_peer_id(), + listeners.clone(), + ct.child_token(), + )); + + if let Some(http_addr) = config.http_addr.clone() { + info!("Runtime multiaddrs available via http at {http_addr}",); + } else { + info!("Runtime multiaddrs not available via http, since http-address flag is not set"); + } + + loop { + tokio::select! { + biased; + _ = ct.cancelled() => { + info!("Relay server shutdown signal received, shutting down gracefully"); + break; + }, + error = server_errors_receiver.recv() => { + match error { + Some(error) => { + warn!("Server error: {}", error); + return Err(error); + } + None => {}, + } + }, + event = node.swarm.select_next_some() => { + // todo: handle swarm events + debug!(?event, "Swarm event"); + + match event { + SwarmEvent::NewListenAddr { address, .. } => { + let mut listeners = listeners.write().await; + listeners.push(address); + } + SwarmEvent::ListenerClosed { addresses, .. } => { + let mut listeners = listeners.write().await; + listeners.retain(|addr| !addresses.contains(addr)); + } + SwarmEvent::ExpiredListenAddr { address, .. } => { + let mut listeners = listeners.write().await; + listeners.retain(|addr| *addr != address); + } + _ => {} + } + } + } + } + + ct.cancel(); + + match tokio::time::timeout(Duration::from_secs(2), enr_server_handle).await { + Ok(Ok(())) => { + info!("ENR server shutdown complete"); + } + Ok(Err(e)) => { + warn!("ENR server shutdown error: {}", e); + } + Err(_) => { + warn!("ENR server shutdown timeout"); + } + } + + Ok(node) +} diff --git a/crates/charon-p2p/src/relay/utils.rs b/crates/charon-p2p/src/relay/utils.rs new file mode 100644 index 00000000..5c039d8f --- /dev/null +++ b/crates/charon-p2p/src/relay/utils.rs @@ -0,0 +1,71 @@ +use std::net::Ipv4Addr; + +use libp2p::{Multiaddr, multiaddr::Protocol}; + +/// Returns true if the multiaddr is TCP. +pub(crate) fn is_tcp_addr(addr: &Multiaddr) -> bool { + addr.iter().any(|p| matches!(p, Protocol::Tcp(_))) +} + +/// Returns true if the multiaddr is QUIC or QUIC-v1. +pub(crate) fn is_quic_addr(addr: &Multiaddr) -> bool { + addr.iter() + .any(|p| matches!(p, Protocol::Quic | Protocol::QuicV1)) +} + +/// Returns true if the multiaddr is a public address. +pub(crate) fn is_public_addr(addr: &Multiaddr) -> bool { + for protocol in addr.iter() { + match protocol { + Protocol::Ip4(ip) => { + return !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_unspecified(); + } + Protocol::Ip6(ip) => { + return !ip.is_loopback() && !ip.is_unspecified(); + } + _ => continue, + } + } + false +} + +/// Extracts IP and TCP port from a multiaddr. +pub(crate) fn extract_ip_and_tcp_port(addr: &Multiaddr) -> Option<(Ipv4Addr, u16)> { + let mut ip: Option = None; + let mut port: Option = None; + + for protocol in addr.iter() { + match protocol { + Protocol::Ip4(i) => ip = Some(i), + Protocol::Tcp(p) => port = Some(p), + _ => {} + } + } + + match (ip, port) { + (Some(i), Some(p)) => Some((i, p)), + _ => None, + } +} + +/// Extracts IP and UDP port from a QUIC multiaddr. +pub(crate) fn extract_ip_and_udp_port(addr: &Multiaddr) -> Option<(Ipv4Addr, u16)> { + let mut ip: Option = None; + let mut port: Option = None; + + for protocol in addr.iter() { + match protocol { + Protocol::Ip4(i) => ip = Some(i), + Protocol::Udp(p) => port = Some(p), + _ => {} + } + } + + match (ip, port) { + (Some(i), Some(p)) => Some((i, p)), + _ => None, + } +} diff --git a/crates/charon-p2p/src/relay/web.rs b/crates/charon-p2p/src/relay/web.rs new file mode 100644 index 00000000..6c577d3b --- /dev/null +++ b/crates/charon-p2p/src/relay/web.rs @@ -0,0 +1,334 @@ +use std::{ + net::{IpAddr, Ipv4Addr}, + sync::Arc, +}; + +use crate::relay::utils; +use axum::{ + Json, Router, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, +}; +use k256::SecretKey; +use libp2p::{Multiaddr, PeerId, multiaddr}; +use tokio::{ + net::TcpListener, + sync::{RwLock, mpsc}, +}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, info, instrument, warn}; + +use crate::{ + config::P2PConfig, + name::peer_name, + relay::{ + config::{Config, EXTERNAL_HOST_RESOLVE_INTERVAL}, + error::RelayP2PError, + }, +}; + +/// Shared application state for HTTP handlers. +#[derive(Clone)] +pub struct AppState { + /// The P2P configuration. + p2p_config: P2PConfig, + /// The secret key for signing ENR records. + secret_key: SecretKey, + /// The peer ID of this node. + peer_id: PeerId, + /// The addresses of this node. + addrs: Arc>>, + /// The resolved external host IP (if configured). + external_host_ip: Arc>>, +} + +impl AppState { + /// Creates a new AppState. + pub fn new( + p2p_config: P2PConfig, + secret_key: SecretKey, + peer_id: PeerId, + addrs: Arc>>, + ) -> Self { + Self { + p2p_config, + secret_key, + peer_id, + addrs, + external_host_ip: Arc::new(RwLock::new(None)), + } + } + + /// Updates the addresses. + pub async fn update_addrs(&self, addrs: Vec) { + let mut current_addrs = self.addrs.write().await; + *current_addrs = addrs; + } + + /// Gets the external host IP if set. + async fn get_external_host_ip(&self) -> Option { + *self.external_host_ip.read().await + } + + /// Sets the external host IP. + async fn set_external_host_ip(&self, ip: Option) { + let mut ext_ip = self.external_host_ip.write().await; + *ext_ip = ip; + } +} + +/// Starts the ENR HTTP server. +#[instrument(skip(server_errors, config, secret_key, peer_id, addrs, ct))] +pub async fn enr_server( + server_errors: mpsc::Sender, + config: Config, + secret_key: SecretKey, + peer_id: PeerId, + addrs: Arc>>, + ct: CancellationToken, +) { + let Some(http_addr) = config.http_addr.clone() else { + warn!("HTTP address is not set, skipping ENR server"); + return; + }; + + info!("Starting ENR server"); + + let state = AppState::new(config.p2p_config.clone(), secret_key, peer_id, addrs); + let state_arc = Arc::new(state); + + // Start external host resolver task if configured + let resolver_handle = if let Some(external_host) = config.p2p_config.external_host { + let state_clone = state_arc.clone(); + let ct_clone = ct.child_token(); + Some(tokio::spawn(async move { + resolve_external_host_periodically(state_clone, external_host, ct_clone).await; + })) + } else { + None + }; + + let router = Router::new() + .route("/", get(multiaddr_handler)) + .route("/enr", get(enr_handler)) + .with_state(state_arc); + + let Ok(listener) = TcpListener::bind(&http_addr).await else { + warn!("Failed to bind HTTP listener to {}", http_addr); + let _ = server_errors + .send(RelayP2PError::FailedToBindHttpListener(http_addr)) + .await; + return; + }; + + info!( + "Relay started {peer_name} on {tcp_addrs} and {udp_addrs}", + peer_name = peer_name(&peer_id), + tcp_addrs = config.p2p_config.tcp_addrs.join(", "), + udp_addrs = config.p2p_config.udp_addrs.join(", "), + ); + + let ct_clone = ct.child_token(); + if let Err(e) = axum::serve(listener, router) + .with_graceful_shutdown(async move { + ct_clone.cancelled().await; + info!("ENR server shutdown complete"); + }) + .await + { + warn!("HTTP server error: {}", e); + let _ = server_errors + .send(RelayP2PError::FailedToServeHTTP(e)) + .await; + } + + ct.cancel(); + + if let Some(resolver_handle) = resolver_handle { + let _ = resolver_handle.await; + } +} + +/// Error response for HTTP handlers. +pub struct HandlerError { + status: StatusCode, + message: String, +} + +impl IntoResponse for HandlerError { + fn into_response(self) -> Response { + (self.status, self.message).into_response() + } +} + +/// Handler that returns the node's ENR. +#[instrument(skip(state))] +pub async fn enr_handler( + State(state): State>, +) -> std::result::Result { + debug!("Getting ENR for node {}", state.peer_id); + + let addrs = state.addrs.read().await; + + if addrs.is_empty() { + return Err(HandlerError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "no addresses".to_string(), + }); + } + + // Sort addresses with public addresses first + let mut sorted_addrs: Vec = addrs.clone(); + sorted_addrs.sort_by(|a, b| { + let a_public = utils::is_public_addr(a); + let b_public = utils::is_public_addr(b); + // Public addresses should come first + b_public.cmp(&a_public) + }); + + // Find TCP and UDP addresses + let mut tcp_addr: Option<(Ipv4Addr, u16)> = None; + let mut udp_addr: Option<(Ipv4Addr, u16)> = None; + + for addr in &sorted_addrs { + if tcp_addr.is_none() + && utils::is_tcp_addr(addr) + && let Some((ip, port)) = utils::extract_ip_and_tcp_port(addr) + { + tcp_addr = Some((apply_ip_override(&state, ip).await, port)); + } + + if udp_addr.is_none() + && utils::is_quic_addr(addr) + && let Some((ip, port)) = utils::extract_ip_and_udp_port(addr) + { + udp_addr = Some((apply_ip_override(&state, ip).await, port)); + } + + if tcp_addr.is_some() && udp_addr.is_some() { + break; + } + } + + // Determine final IP, TCP port, and UDP port + let (ip, tcp_port, udp_port) = match (tcp_addr, udp_addr) { + (Some((tcp_ip, tcp_p)), Some((udp_ip, udp_p))) => { + if tcp_ip != udp_ip { + return Err(HandlerError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("conflicting IP addresses: tcp={}, udp={}", tcp_ip, udp_ip), + }); + } + (tcp_ip, tcp_p, udp_p) + } + (Some((ip, tcp_p)), None) => (ip, tcp_p, 9999), // Dummy UDP port + (None, Some((ip, udp_p))) => (ip, 9999, udp_p), // Dummy TCP port + (None, None) => { + return Err(HandlerError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: "no udp or tcp addresses provided".to_string(), + }); + } + }; + + // Create ENR record + let record = charon_eth2::enr::Record::new( + state.secret_key.clone(), + vec![ + charon_eth2::enr::with_ip_impl(ip), + charon_eth2::enr::with_tcp_impl(tcp_port), + charon_eth2::enr::with_udp_impl(udp_port), + ], + ) + .map_err(|e| HandlerError { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: format!("failed to create ENR: {}", e), + })?; + + Ok(record.to_string()) +} + +/// Applies IP override from config (external_ip or resolved external_host). +async fn apply_ip_override(state: &AppState, original_ip: Ipv4Addr) -> Ipv4Addr { + // First check external_ip config + if let Some(external_ip) = &state.p2p_config.external_ip + && let Ok(ip) = external_ip.parse::() + { + return ip; + } + + // Then check resolved external_host + if let Some(ip) = state.get_external_host_ip().await { + return ip; + } + + original_ip +} + +/// Handler that returns the node's multiaddrs as JSON. +#[instrument(skip(state))] +pub async fn multiaddr_handler( + State(state): State>, +) -> std::result::Result>, HandlerError> { + debug!("Getting multiaddrs for node {}", state.peer_id); + + let addrs = state.addrs.read().await.clone(); + + // Encapsulate peer ID into each address + let full_addrs: Vec = addrs + .into_iter() + .map(|addr| addr.with(multiaddr::Protocol::P2p(state.peer_id))) + .map(|addr| addr.to_string()) + .collect(); + + Ok(Json(full_addrs)) +} + +/// Periodically resolves the external host to an IP address. +#[instrument(skip(state, ct))] +async fn resolve_external_host_periodically( + state: Arc, + external_host: String, + ct: CancellationToken, +) { + info!("Starting external host resolver"); + + // Resolve immediately on startup + resolve_external_host(state.clone(), &external_host).await; + + // Then resolve periodically + let mut interval = tokio::time::interval(EXTERNAL_HOST_RESOLVE_INTERVAL); + interval.tick().await; // Skip the first immediate tick + + loop { + tokio::select! { + biased; + _ = ct.cancelled() => { + info!("External host resolver shutdown complete"); + break; + } + _ = interval.tick() => { + resolve_external_host(state.clone(), &external_host).await; + } + } + } +} + +/// Resolves the external host to an IP address. +async fn resolve_external_host(state: Arc, external_host: &str) { + match tokio::net::lookup_host(external_host).await { + Ok(mut addrs) => { + if let Some(addr) = addrs.next() + && let IpAddr::V4(ipv4) = addr.ip() + { + debug!("Resolved external host {external_host} to {ipv4}"); + state.set_external_host_ip(Some(ipv4)).await; + } + } + Err(e) => { + warn!("Failed to resolve external host {}: {}", external_host, e); + } + } +} diff --git a/crates/tracing/src/lib.rs b/crates/tracing/src/lib.rs index b9c93282..542d6171 100644 --- a/crates/tracing/src/lib.rs +++ b/crates/tracing/src/lib.rs @@ -17,3 +17,5 @@ pub mod layers; pub mod metrics; pub use config::{ConsoleConfig, LokiConfig, TracingConfig, TracingConfigBuilder}; + +pub use init::init; From 152fbcf1767da2be7be646ca261788e5c36ca994 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:56:18 +0200 Subject: [PATCH 17/24] chore: fix linter, polish code --- crates/charon-p2p/examples/p2p.rs | 1 - crates/charon-p2p/examples/relay_server.rs | 8 ++--- crates/charon-p2p/src/behaviours/pluto.rs | 2 +- .../charon-p2p/src/behaviours/pluto_mdns.rs | 10 +----- crates/charon-p2p/src/gater/mod.rs | 4 +-- crates/charon-p2p/src/peer.rs | 12 ++++++- crates/charon-p2p/src/relay/config.rs | 33 +++---------------- crates/charon-p2p/src/relay/error.rs | 13 +++++++- crates/charon-p2p/src/relay/metrics.rs | 3 ++ crates/charon-p2p/src/relay/mod.rs | 5 +-- crates/charon-p2p/src/relay/p2p.rs | 19 ++++++----- 11 files changed, 51 insertions(+), 59 deletions(-) diff --git a/crates/charon-p2p/examples/p2p.rs b/crates/charon-p2p/examples/p2p.rs index e4d9a77b..491d1032 100644 --- a/crates/charon-p2p/examples/p2p.rs +++ b/crates/charon-p2p/examples/p2p.rs @@ -12,7 +12,6 @@ use charon_p2p::{ pluto_mdns::{PlutoMdnsBehaviour, PlutoMdnsBehaviourEvent}, }, config::P2PConfig, - gater::ConnGater, p2p::{Node, NodeType}, }; use clap::Parser; diff --git a/crates/charon-p2p/examples/relay_server.rs b/crates/charon-p2p/examples/relay_server.rs index c750dccb..6955c3f3 100644 --- a/crates/charon-p2p/examples/relay_server.rs +++ b/crates/charon-p2p/examples/relay_server.rs @@ -14,14 +14,14 @@ use tracing::info; #[tokio::main] async fn main() { - charon_tracing::init(&TracingConfig::default()).unwrap(); + charon_tracing::init(&TracingConfig::default()).expect("Failed to initialize tracing"); let config = Config::builder() .with_p2p_config( P2PConfig::builder() .with_tcp_addrs(vec![ multiaddr::Multiaddr::from_str("/ip4/0.0.0.0/tcp/0") - .unwrap() + .expect("Failed to parse multiaddress") .to_string(), ]) .build(), @@ -36,12 +36,12 @@ async fn main() { tokio::select! { result = run_relay_p2p_node(&config, key, ct.child_token()) => { - result.unwrap(); + result.expect("Failed to run relay P2P node"); } _ = tokio::signal::ctrl_c() => { info!("Shutdown signal received, shutting down gracefully..."); ct.cancel(); - + } } diff --git a/crates/charon-p2p/src/behaviours/pluto.rs b/crates/charon-p2p/src/behaviours/pluto.rs index ad463e8c..a9c978df 100644 --- a/crates/charon-p2p/src/behaviours/pluto.rs +++ b/crates/charon-p2p/src/behaviours/pluto.rs @@ -32,7 +32,7 @@ impl PlutoBehaviour { } /// Builder for [`PlutoBehaviour`]. -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct PlutoBehaviourBuilder { gater: Option, identify_protocol: String, diff --git a/crates/charon-p2p/src/behaviours/pluto_mdns.rs b/crates/charon-p2p/src/behaviours/pluto_mdns.rs index f11b4727..3d919c2d 100644 --- a/crates/charon-p2p/src/behaviours/pluto_mdns.rs +++ b/crates/charon-p2p/src/behaviours/pluto_mdns.rs @@ -26,20 +26,12 @@ impl PlutoMdnsBehaviour { } /// Builder for [`PlutoMdnsBehaviour`]. +#[derive(Default, Debug, Clone)] pub struct PlutoMdnsBehaviourBuilder { pluto: PlutoBehaviourBuilder, mdns_config: mdns::Config, } -impl Default for PlutoMdnsBehaviourBuilder { - fn default() -> Self { - Self { - pluto: PlutoBehaviourBuilder::default(), - mdns_config: mdns::Config::default(), - } - } -} - impl PlutoMdnsBehaviourBuilder { /// Creates a new builder with default configuration. pub fn new() -> Self { diff --git a/crates/charon-p2p/src/gater/mod.rs b/crates/charon-p2p/src/gater/mod.rs index 1c59345e..9e06d9d4 100644 --- a/crates/charon-p2p/src/gater/mod.rs +++ b/crates/charon-p2p/src/gater/mod.rs @@ -26,7 +26,7 @@ use crate::peer::MutablePeer; mod handler; /// Configuration for the connection gater. -#[derive(Clone, Default)] +#[derive(Debug, Clone, Default)] pub struct Config { peer_ids: HashSet, relays: Vec>, @@ -68,7 +68,7 @@ impl Config { } /// ConnGater filters incoming and outgoing connections by the cluster peers. -#[derive(Clone, Default)] +#[derive(Debug, Clone, Default)] pub struct ConnGater { config: Config, events: VecDeque, diff --git a/crates/charon-p2p/src/peer.rs b/crates/charon-p2p/src/peer.rs index 9559c7a4..1a9eba0e 100644 --- a/crates/charon-p2p/src/peer.rs +++ b/crates/charon-p2p/src/peer.rs @@ -127,13 +127,14 @@ pub enum MutablePeerError { } /// MutablePeer is a mutable peer that can be updated. +#[derive(Debug, Clone)] pub struct MutablePeer { /// Inner state of the mutable peer. inner: Arc>, } /// Subscriber is a function that is called when the peer is updated. -pub type Subscriber = Box; +pub type Subscriber = Box; /// MutablePeerInner is the inner state of a MutablePeer. pub struct MutablePeerInner { @@ -144,6 +145,15 @@ pub struct MutablePeerInner { subs: Vec, } +impl std::fmt::Debug for MutablePeerInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MutablePeerInner") + .field("peer", &self.peer) + .field("subs", &format!("[{} subscribers]", self.subs.len())) + .finish() + } +} + type MutablePeerResult = std::result::Result; impl MutablePeer { diff --git a/crates/charon-p2p/src/relay/config.rs b/crates/charon-p2p/src/relay/config.rs index 091d59fc..8339b2eb 100644 --- a/crates/charon-p2p/src/relay/config.rs +++ b/crates/charon-p2p/src/relay/config.rs @@ -5,9 +5,13 @@ use libp2p::relay; use crate::config::P2PConfig; +/// One hour in seconds. pub const ONE_HOUR_SECONDS: u64 = 60 * 60; +/// One minute in seconds. pub const ONE_MINUTE_SECONDS: u64 = 60; +/// 32 MB in bytes. pub const MB_32: u64 = 32 * 1024 * 1024; +/// External host resolve interval. pub const EXTERNAL_HOST_RESOLVE_INTERVAL: Duration = Duration::from_secs(5 * 60); // todo: make more typed @@ -39,35 +43,6 @@ pub struct Config { } impl Config { - /// Creates a new configuration. - pub fn new( - data_dir: PathBuf, - http_addr: Option, - monitoring_addr: String, - debug_addr: String, - p2p_config: P2PConfig, - log_config: TracingConfig, - auto_p2p_key: bool, - max_res_per_peer: usize, - max_conns: usize, - filter_private_addrs: bool, - libp2p_log_level: String, - ) -> Self { - Self { - data_dir, - http_addr, - monitoring_addr, - debug_addr, - p2p_config, - log_config, - auto_p2p_key, - max_res_per_peer, - max_conns, - filter_private_addrs, - libp2p_log_level, - } - } - /// Returns a new builder for configuring a relay P2P layer. pub fn builder() -> ConfigBuilder { ConfigBuilder::new() diff --git a/crates/charon-p2p/src/relay/error.rs b/crates/charon-p2p/src/relay/error.rs index 94992bd4..36876fa9 100644 --- a/crates/charon-p2p/src/relay/error.rs +++ b/crates/charon-p2p/src/relay/error.rs @@ -1,3 +1,5 @@ +use libp2p::multiaddr; + use crate::p2p::P2PError; /// Relay P2P error. @@ -18,6 +20,15 @@ pub enum RelayP2PError { /// Failed to serve HTTP. #[error("Failed to serve HTTP: {0}")] FailedToServeHTTP(std::io::Error), + + /// Failed to listen on address. + #[error("Failed to listen on address: {0}")] + FailedToListenOnAddress(libp2p::TransportError), + + /// Failed to parse multiaddress. + #[error("Failed to parse multiaddress: {0}")] + FailedToParseMultiaddr(#[from] multiaddr::Error), } -pub type Result = std::result::Result; +/// Relay P2P result. +pub(crate) type Result = std::result::Result; diff --git a/crates/charon-p2p/src/relay/metrics.rs b/crates/charon-p2p/src/relay/metrics.rs index 420722cb..ddc17e44 100644 --- a/crates/charon-p2p/src/relay/metrics.rs +++ b/crates/charon-p2p/src/relay/metrics.rs @@ -2,6 +2,7 @@ use vise::*; use crate::metrics::BUCKETS; +/// Metrics for the relay P2P layer. #[derive(Debug, Metrics)] #[metrics(prefix = "relay_p2p")] pub struct RelayMetrics { @@ -22,11 +23,13 @@ pub struct RelayMetrics { ping_latency: Family, } +/// Labels for peer with peer cluster. #[derive(Debug, Clone, PartialEq, Eq, Hash, EncodeLabelSet)] pub struct PeerWithPeerClusterLabels { peer: String, peer_cluster: String, } +/// Global metrics for the relay P2P layer. #[vise::register] pub static RELAY_METRICS: Global = Global::new(); diff --git a/crates/charon-p2p/src/relay/mod.rs b/crates/charon-p2p/src/relay/mod.rs index 362dda9a..83604f76 100644 --- a/crates/charon-p2p/src/relay/mod.rs +++ b/crates/charon-p2p/src/relay/mod.rs @@ -1,4 +1,3 @@ -#![allow(missing_docs)] //! Everything related to relay client / server. /// P2P. @@ -19,4 +18,6 @@ pub mod error; /// Utils. pub mod utils; -pub use error::{RelayP2PError, Result}; +pub use error::RelayP2PError; + +pub(crate) use error::Result; diff --git a/crates/charon-p2p/src/relay/p2p.rs b/crates/charon-p2p/src/relay/p2p.rs index 1ac3564e..5a306283 100644 --- a/crates/charon-p2p/src/relay/p2p.rs +++ b/crates/charon-p2p/src/relay/p2p.rs @@ -13,7 +13,7 @@ use crate::{ gater::ConnGater, p2p::Node, relay::{ - Result, + RelayP2PError, Result, config::{Config, create_relay_config}, web::enr_server, }, @@ -39,11 +39,15 @@ pub async fn run_relay_p2p_node( for tcp_addr in config.p2p_config.tcp_addrs.iter() { debug!("Listening on TCP address {}", tcp_addr); - node.swarm.listen_on(tcp_addr.parse().unwrap()).unwrap(); + node.swarm + .listen_on(tcp_addr.parse()?) + .map_err(RelayP2PError::FailedToListenOnAddress)?; } for udp_addr in config.p2p_config.udp_addrs.iter() { debug!("Listening on UDP address {}", udp_addr); - node.swarm.listen_on(udp_addr.parse().unwrap()).unwrap(); + node.swarm + .listen_on(udp_addr.parse()?) + .map_err(RelayP2PError::FailedToListenOnAddress)?; } let (server_errors, mut server_errors_receiver) = mpsc::channel(3); @@ -73,12 +77,9 @@ pub async fn run_relay_p2p_node( break; }, error = server_errors_receiver.recv() => { - match error { - Some(error) => { - warn!("Server error: {}", error); - return Err(error); - } - None => {}, + if let Some(error) = error { + warn!("Server error: {}", error); + return Err(error); } }, event = node.swarm.select_next_some() => { From 529d79462d03941292278eb4d82b48abc50d00ea Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:00:08 +0200 Subject: [PATCH 18/24] fix: cargo deny dependency issue --- Cargo.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a6fe28d..2af6a3fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -720,7 +720,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -731,7 +731,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2308,7 +2308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4389,7 +4389,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -5430,9 +5430,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.0" +version = "1.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" +checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -5511,7 +5511,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6161,7 +6161,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6914,7 +6914,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] From 1eb752e7e0bc9fc12ef3524cdb13fb6306807f22 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 30 Dec 2025 11:27:10 +0200 Subject: [PATCH 19/24] chore: add docs --- crates/charon-p2p/src/relay/config.rs | 1 - crates/charon-p2p/src/relay/p2p.rs | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/charon-p2p/src/relay/config.rs b/crates/charon-p2p/src/relay/config.rs index 8339b2eb..daae0faa 100644 --- a/crates/charon-p2p/src/relay/config.rs +++ b/crates/charon-p2p/src/relay/config.rs @@ -14,7 +14,6 @@ pub const MB_32: u64 = 32 * 1024 * 1024; /// External host resolve interval. pub const EXTERNAL_HOST_RESOLVE_INTERVAL: Duration = Duration::from_secs(5 * 60); -// todo: make more typed /// Configuration for the relay P2P layer. #[derive(Default, Debug, Clone)] pub struct Config { diff --git a/crates/charon-p2p/src/relay/p2p.rs b/crates/charon-p2p/src/relay/p2p.rs index 5a306283..d6b86303 100644 --- a/crates/charon-p2p/src/relay/p2p.rs +++ b/crates/charon-p2p/src/relay/p2p.rs @@ -1,5 +1,3 @@ -#![allow(missing_docs)] - use std::{sync::Arc, time::Duration}; use k256::SecretKey; @@ -19,6 +17,7 @@ use crate::{ }, }; +/// Runs a relay P2P node. #[instrument(skip(config, key, ct))] pub async fn run_relay_p2p_node( config: &Config, From 7b723690f3880a4d143f35cdf77448c786d9750c Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 30 Dec 2025 13:13:10 +0200 Subject: [PATCH 20/24] refactor: move relay-server to a different crate --- Cargo.lock | 19 +++++++++++++ Cargo.toml | 2 ++ crates/charon-p2p/src/behaviours/mod.rs | 3 --- crates/charon-p2p/src/lib.rs | 3 --- crates/relay-server/Cargo.toml | 27 +++++++++++++++++++ .../examples/relay_server.rs | 6 ++--- .../src/behaviour.rs} | 3 ++- .../src/relay => relay-server/src}/config.rs | 2 +- .../src/relay => relay-server/src}/error.rs | 4 +-- .../relay/mod.rs => relay-server/src/lib.rs} | 3 +++ .../src/relay => relay-server/src}/metrics.rs | 2 +- .../src/relay => relay-server/src}/p2p.rs | 16 +++++------ .../src/relay => relay-server/src}/utils.rs | 0 .../src/relay => relay-server/src}/web.rs | 11 +++----- 14 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 crates/relay-server/Cargo.toml rename crates/{charon-p2p => relay-server}/examples/relay_server.rs (92%) rename crates/{charon-p2p/src/behaviours/relay_server.rs => relay-server/src/behaviour.rs} (96%) rename crates/{charon-p2p/src/relay => relay-server/src}/config.rs (99%) rename crates/{charon-p2p/src/relay => relay-server/src}/error.rs (90%) rename crates/{charon-p2p/src/relay/mod.rs => relay-server/src/lib.rs} (88%) rename crates/{charon-p2p/src/relay => relay-server/src}/metrics.rs (97%) rename crates/{charon-p2p/src/relay => relay-server/src}/p2p.rs (94%) rename crates/{charon-p2p/src/relay => relay-server/src}/utils.rs (100%) rename crates/{charon-p2p/src/relay => relay-server/src}/web.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 2af6a3fb..09dd7ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,25 @@ dependencies = [ "vise-exporter", ] +[[package]] +name = "charon-relay-server" +version = "0.1.0" +dependencies = [ + "axum", + "charon-eth2", + "charon-p2p", + "charon-tracing", + "k256", + "libp2p", + "rand 0.8.5", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", + "vise", + "vise-exporter", +] + [[package]] name = "charon-testutil" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index cab7219c..d74633b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/charon-p2p", "crates/charon-testutil", "crates/tracing", + "crates/relay-server" ] resolver = "3" @@ -65,6 +66,7 @@ charon-k1util = { path = "crates/charon-k1util" } charon-p2p = { path = "crates/charon-p2p" } charon-testutil = { path = "crates/charon-testutil" } charon-tracing = { path = "crates/tracing" } +charon-relay-server = { path = "crates/relay-server" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/charon-p2p/src/behaviours/mod.rs b/crates/charon-p2p/src/behaviours/mod.rs index 272a08e8..5a16f8ed 100644 --- a/crates/charon-p2p/src/behaviours/mod.rs +++ b/crates/charon-p2p/src/behaviours/mod.rs @@ -7,6 +7,3 @@ pub mod pluto; #[cfg(feature = "mdns")] /// Pluto Mdns behaviour. pub mod pluto_mdns; - -/// Relay server behaviour. -pub mod relay_server; diff --git a/crates/charon-p2p/src/lib.rs b/crates/charon-p2p/src/lib.rs index 4aaeb1cd..2043c8e7 100644 --- a/crates/charon-p2p/src/lib.rs +++ b/crates/charon-p2p/src/lib.rs @@ -28,6 +28,3 @@ pub mod behaviours; /// K1 utilities. pub mod k1; - -/// Everything related to relay client / server. -pub mod relay; diff --git a/crates/relay-server/Cargo.toml b/crates/relay-server/Cargo.toml new file mode 100644 index 00000000..492b6062 --- /dev/null +++ b/crates/relay-server/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "charon-relay-server" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +axum.workspace = true +libp2p.workspace = true +thiserror.workspace = true +k256.workspace = true +charon-eth2.workspace = true +vise.workspace = true +tokio.workspace = true +tokio-util.workspace = true +rand.workspace = true +charon-tracing.workspace = true +tracing.workspace = true +charon-p2p.workspace = true + +[dev-dependencies] +vise-exporter.workspace = true + +[lints] +workspace = true \ No newline at end of file diff --git a/crates/charon-p2p/examples/relay_server.rs b/crates/relay-server/examples/relay_server.rs similarity index 92% rename from crates/charon-p2p/examples/relay_server.rs rename to crates/relay-server/examples/relay_server.rs index 6955c3f3..b330a7d7 100644 --- a/crates/charon-p2p/examples/relay_server.rs +++ b/crates/relay-server/examples/relay_server.rs @@ -1,10 +1,8 @@ #![allow(missing_docs)] use std::str::FromStr; -use charon_p2p::{ - config::P2PConfig, - relay::{config::Config, p2p::run_relay_p2p_node}, -}; +use charon_p2p::config::P2PConfig; +use charon_relay_server::{config::Config, p2p::run_relay_p2p_node}; use charon_tracing::TracingConfig; use k256::SecretKey; use libp2p::multiaddr; diff --git a/crates/charon-p2p/src/behaviours/relay_server.rs b/crates/relay-server/src/behaviour.rs similarity index 96% rename from crates/charon-p2p/src/behaviours/relay_server.rs rename to crates/relay-server/src/behaviour.rs index baf2a2d9..d5a4847c 100644 --- a/crates/charon-p2p/src/behaviours/relay_server.rs +++ b/crates/relay-server/src/behaviour.rs @@ -1,10 +1,11 @@ +#![allow(missing_docs)] // we need to allow missing docs for the derive macro //! Relay server behaviour. use std::time::Duration; use libp2p::{identify, identity::Keypair, ping, relay, swarm::NetworkBehaviour}; -use crate::gater::ConnGater; +use charon_p2p::gater::ConnGater; /// Relay server network behaviour. #[derive(NetworkBehaviour)] diff --git a/crates/charon-p2p/src/relay/config.rs b/crates/relay-server/src/config.rs similarity index 99% rename from crates/charon-p2p/src/relay/config.rs rename to crates/relay-server/src/config.rs index daae0faa..f4cfc3e9 100644 --- a/crates/charon-p2p/src/relay/config.rs +++ b/crates/relay-server/src/config.rs @@ -3,7 +3,7 @@ use std::{path::PathBuf, time::Duration}; use charon_tracing::TracingConfig; use libp2p::relay; -use crate::config::P2PConfig; +use charon_p2p::config::P2PConfig; /// One hour in seconds. pub const ONE_HOUR_SECONDS: u64 = 60 * 60; diff --git a/crates/charon-p2p/src/relay/error.rs b/crates/relay-server/src/error.rs similarity index 90% rename from crates/charon-p2p/src/relay/error.rs rename to crates/relay-server/src/error.rs index 36876fa9..d2994712 100644 --- a/crates/charon-p2p/src/relay/error.rs +++ b/crates/relay-server/src/error.rs @@ -1,13 +1,13 @@ use libp2p::multiaddr; -use crate::p2p::P2PError; +use charon_p2p::p2p::P2PError; /// Relay P2P error. #[derive(Debug, thiserror::Error)] pub enum RelayP2PError { /// Failed to load private key. #[error("Failed to load private key")] - FailedToLoadPrivateKey(#[from] crate::k1::K1Error), + FailedToLoadPrivateKey(#[from] charon_p2p::k1::K1Error), /// P2P error. #[error("P2P error: {0}")] diff --git a/crates/charon-p2p/src/relay/mod.rs b/crates/relay-server/src/lib.rs similarity index 88% rename from crates/charon-p2p/src/relay/mod.rs rename to crates/relay-server/src/lib.rs index 83604f76..e085bfef 100644 --- a/crates/charon-p2p/src/relay/mod.rs +++ b/crates/relay-server/src/lib.rs @@ -18,6 +18,9 @@ pub mod error; /// Utils. pub mod utils; +/// Behaviour. +pub mod behaviour; + pub use error::RelayP2PError; pub(crate) use error::Result; diff --git a/crates/charon-p2p/src/relay/metrics.rs b/crates/relay-server/src/metrics.rs similarity index 97% rename from crates/charon-p2p/src/relay/metrics.rs rename to crates/relay-server/src/metrics.rs index ddc17e44..77c5fd24 100644 --- a/crates/charon-p2p/src/relay/metrics.rs +++ b/crates/relay-server/src/metrics.rs @@ -1,6 +1,6 @@ use vise::*; -use crate::metrics::BUCKETS; +use charon_p2p::metrics::BUCKETS; /// Metrics for the relay P2P layer. #[derive(Debug, Metrics)] diff --git a/crates/charon-p2p/src/relay/p2p.rs b/crates/relay-server/src/p2p.rs similarity index 94% rename from crates/charon-p2p/src/relay/p2p.rs rename to crates/relay-server/src/p2p.rs index d6b86303..9ddc00f3 100644 --- a/crates/charon-p2p/src/relay/p2p.rs +++ b/crates/relay-server/src/p2p.rs @@ -1,3 +1,5 @@ +#![allow(missing_docs)] + use std::{sync::Arc, time::Duration}; use k256::SecretKey; @@ -7,15 +9,13 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, instrument, warn}; use crate::{ - behaviours::relay_server::RelayServerBehaviour, - gater::ConnGater, - p2p::Node, - relay::{ - RelayP2PError, Result, - config::{Config, create_relay_config}, - web::enr_server, - }, + Result, + behaviour::RelayServerBehaviour, + config::{Config, create_relay_config}, + error::RelayP2PError, + web::enr_server, }; +use charon_p2p::{gater::ConnGater, p2p::Node}; /// Runs a relay P2P node. #[instrument(skip(config, key, ct))] diff --git a/crates/charon-p2p/src/relay/utils.rs b/crates/relay-server/src/utils.rs similarity index 100% rename from crates/charon-p2p/src/relay/utils.rs rename to crates/relay-server/src/utils.rs diff --git a/crates/charon-p2p/src/relay/web.rs b/crates/relay-server/src/web.rs similarity index 98% rename from crates/charon-p2p/src/relay/web.rs rename to crates/relay-server/src/web.rs index 6c577d3b..e5aad150 100644 --- a/crates/charon-p2p/src/relay/web.rs +++ b/crates/relay-server/src/web.rs @@ -3,7 +3,7 @@ use std::{ sync::Arc, }; -use crate::relay::utils; +use crate::utils; use axum::{ Json, Router, extract::State, @@ -21,13 +21,10 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, instrument, warn}; use crate::{ - config::P2PConfig, - name::peer_name, - relay::{ - config::{Config, EXTERNAL_HOST_RESOLVE_INTERVAL}, - error::RelayP2PError, - }, + config::{Config, EXTERNAL_HOST_RESOLVE_INTERVAL}, + error::RelayP2PError, }; +use charon_p2p::{config::P2PConfig, name::peer_name}; /// Shared application state for HTTP handlers. #[derive(Clone)] From ee147d0d2f846ab1a981b58d0bc5c8788e031052 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:28:11 +0200 Subject: [PATCH 21/24] feat: add peerinfo (mvp) --- Cargo.lock | 25 ++ Cargo.toml | 4 +- crates/charon/build.rs | 3 +- crates/charon/src/lib.rs | 3 - crates/charon/src/peerinfo/mod.rs | 2 - crates/peerinfo/Cargo.toml | 34 ++ crates/peerinfo/build.rs | 11 + crates/peerinfo/examples/peerinfo.rs | 224 +++++++++++++ crates/peerinfo/src/behaviour.rs | 138 ++++++++ crates/peerinfo/src/config.rs | 125 +++++++ crates/peerinfo/src/failure.rs | 63 ++++ crates/peerinfo/src/handler.rs | 309 ++++++++++++++++++ crates/peerinfo/src/lib.rs | 67 ++++ .../src}/peerinfopb/mod.rs | 0 .../src}/peerinfopb/v1.rs | 0 .../src}/peerinfopb/v1/peerinfo.proto | 0 .../src}/peerinfopb/v1/peerinfo.rs | 0 crates/peerinfo/src/protocol.rs | 93 ++++++ 18 files changed, 1093 insertions(+), 8 deletions(-) delete mode 100644 crates/charon/src/peerinfo/mod.rs create mode 100644 crates/peerinfo/Cargo.toml create mode 100644 crates/peerinfo/build.rs create mode 100644 crates/peerinfo/examples/peerinfo.rs create mode 100644 crates/peerinfo/src/behaviour.rs create mode 100644 crates/peerinfo/src/config.rs create mode 100644 crates/peerinfo/src/failure.rs create mode 100644 crates/peerinfo/src/handler.rs create mode 100644 crates/peerinfo/src/lib.rs rename crates/{charon/src/peerinfo => peerinfo/src}/peerinfopb/mod.rs (100%) rename crates/{charon/src/peerinfo => peerinfo/src}/peerinfopb/v1.rs (100%) rename crates/{charon/src/peerinfo => peerinfo/src}/peerinfopb/v1/peerinfo.proto (100%) rename crates/{charon/src/peerinfo => peerinfo/src}/peerinfopb/v1/peerinfo.rs (100%) create mode 100644 crates/peerinfo/src/protocol.rs diff --git a/Cargo.lock b/Cargo.lock index 09dd7ba3..e260fa31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,27 @@ dependencies = [ "vise-exporter", ] +[[package]] +name = "charon-peerinfo" +version = "0.1.0" +dependencies = [ + "anyhow", + "charon-build-proto", + "chrono", + "clap", + "futures", + "futures-timer", + "hex", + "libp2p", + "prost 0.14.1", + "prost-types 0.14.1", + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-subscriber", + "unsigned-varint 0.8.0", +] + [[package]] name = "charon-relay-server" version = "0.1.0" @@ -6645,6 +6666,10 @@ name = "unsigned-varint" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" +dependencies = [ + "futures-io", + "futures-util", +] [[package]] name = "untrusted" diff --git a/Cargo.toml b/Cargo.toml index d74633b7..4c7e6262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ members = [ "crates/charon-p2p", "crates/charon-testutil", "crates/tracing", - "crates/relay-server" + "crates/relay-server", + "crates/peerinfo" ] resolver = "3" @@ -67,6 +68,7 @@ charon-p2p = { path = "crates/charon-p2p" } charon-testutil = { path = "crates/charon-testutil" } charon-tracing = { path = "crates/tracing" } charon-relay-server = { path = "crates/relay-server" } +charon-peerinfo = { path = "crates/peerinfo" } [workspace.lints.rust] missing_docs = "deny" diff --git a/crates/charon/build.rs b/crates/charon/build.rs index ef2ad4cd..a1db1bae 100644 --- a/crates/charon/build.rs +++ b/crates/charon/build.rs @@ -1,11 +1,10 @@ -//! # Charon Peerinfo Build Script +//! # Charon Build Script //! //! This build script compiles the protobuf files. use std::io::Result; fn main() -> Result<()> { - charon_build_proto::compile_protos("src/peerinfo/peerinfopb/v1")?; charon_build_proto::compile_protos("src/log/loki/lokipb/v1")?; Ok(()) diff --git a/crates/charon/src/lib.rs b/crates/charon/src/lib.rs index 5baecc58..a7e7a71d 100644 --- a/crates/charon/src/lib.rs +++ b/crates/charon/src/lib.rs @@ -4,9 +4,6 @@ //! coordination for Ethereum 2.0 validators. This crate serves as the primary //! entry point for the Charon distributed validator node implementation. -/// Peerinfo. -pub mod peerinfo; - /// Log pub mod log; diff --git a/crates/charon/src/peerinfo/mod.rs b/crates/charon/src/peerinfo/mod.rs deleted file mode 100644 index f2f898d3..00000000 --- a/crates/charon/src/peerinfo/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -/// Peerinfo protobuf definitions. -pub mod peerinfopb; diff --git a/crates/peerinfo/Cargo.toml b/crates/peerinfo/Cargo.toml new file mode 100644 index 00000000..7fb5d547 --- /dev/null +++ b/crates/peerinfo/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "charon-peerinfo" +version.workspace = true +edition.workspace = true +repository.workspace = true +license.workspace = true +publish.workspace = true + +[dependencies] +prost.workspace = true +prost-types.workspace = true +thiserror.workspace = true +libp2p.workspace = true +futures = "0.3" +futures-timer = "3.0" +tracing.workspace = true +chrono.workspace = true +unsigned-varint = { version = "0.8", features = ["futures"] } + +[build-dependencies] +charon-build-proto.workspace = true + +[dev-dependencies] +anyhow.workspace = true +clap.workspace = true +tokio.workspace = true +tracing-subscriber.workspace = true +hex.workspace = true + +[lints] +workspace = true + +[[example]] +name = "peerinfo" diff --git a/crates/peerinfo/build.rs b/crates/peerinfo/build.rs new file mode 100644 index 00000000..d3108e09 --- /dev/null +++ b/crates/peerinfo/build.rs @@ -0,0 +1,11 @@ +//! # Charon Peerinfo Build Script +//! +//! This build script compiles the protobuf files. + +use std::io::Result; + +fn main() -> Result<()> { + charon_build_proto::compile_protos("src/peerinfopb/v1")?; + + Ok(()) +} diff --git a/crates/peerinfo/examples/peerinfo.rs b/crates/peerinfo/examples/peerinfo.rs new file mode 100644 index 00000000..10e919f9 --- /dev/null +++ b/crates/peerinfo/examples/peerinfo.rs @@ -0,0 +1,224 @@ +//! Peerinfo example +//! +//! This example demonstrates the peerinfo protocol by creating two nodes +//! that exchange peer information with each other using mDNS auto-discovery. +//! +//! Run with: +//! ```sh +//! cargo run --example peerinfo -p charon-peerinfo +//! ``` +//! +//! Run two instances on different ports - they will auto-discover each other: +//! +//! Terminal 1: `cargo run --example peerinfo -p charon-peerinfo -- --port 4001` +//! Terminal 2: `cargo run --example peerinfo -p charon-peerinfo -- --port 4002` +#![allow(missing_docs)] +use std::time::Duration; + +use charon_peerinfo::{Behaviour, Config, Event, LocalPeerInfo}; +use clap::Parser; +use libp2p::{ + Multiaddr, Swarm, SwarmBuilder, + futures::StreamExt, + identify, mdns, noise, ping, + swarm::{NetworkBehaviour, SwarmEvent}, + tcp, yamux, +}; +use tokio::signal; +use tracing_subscriber::EnvFilter; + +/// Command line arguments +#[derive(Debug, Parser)] +#[command(name = "peerinfo-example")] +#[command(about = "Demonstrates the peerinfo protocol with mDNS discovery")] +pub struct Args { + /// The port to listen on + #[arg(short, long, default_value = "4001")] + pub port: u16, + + /// Optional address to dial + #[arg(short, long)] + pub dial: Option, + + /// Nickname for this node + #[arg(short, long, default_value = "example-node")] + pub nickname: String, + + /// Peer info exchange interval in seconds + #[arg(short, long, default_value = "5")] + pub interval: u64, +} + +/// Combined behaviour with peerinfo, identify, ping, and mdns +#[derive(NetworkBehaviour)] +pub struct CombinedBehaviour { + pub peer_info: Behaviour, + pub identify: identify::Behaviour, + pub ping: ping::Behaviour, + pub mdns: mdns::tokio::Behaviour, +} + +pub type CombinedEvent = CombinedBehaviourEvent; + +fn build_swarm(peerinfo_config: Config) -> anyhow::Result> { + let swarm = SwarmBuilder::with_new_identity() + .with_tokio() + .with_tcp( + tcp::Config::default(), + noise::Config::new, + yamux::Config::default, + )? + .with_behaviour(|key| { + Ok(CombinedBehaviour { + peer_info: Behaviour::new(peerinfo_config), + identify: identify::Behaviour::new(identify::Config::new( + "/peerinfo-example/1.0.0".to_string(), + key.public(), + )), + ping: ping::Behaviour::new( + ping::Config::new() + .with_interval(Duration::from_secs(15)) + .with_timeout(Duration::from_secs(10)), + ), + mdns: mdns::tokio::Behaviour::new( + mdns::Config::default(), + key.public().to_peer_id(), + )?, + }) + })? + .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) + .build(); + + Ok(swarm) +} + +fn handle_event(event: SwarmEvent, swarm: &mut Swarm) { + match event { + SwarmEvent::NewListenAddr { address, .. } => { + tracing::info!("Listening on {address}"); + } + SwarmEvent::ConnectionEstablished { + peer_id, endpoint, .. + } => { + tracing::info!( + "Connection established with {peer_id} via {}", + endpoint.get_remote_address() + ); + } + SwarmEvent::ConnectionClosed { peer_id, cause, .. } => { + tracing::info!("Connection closed with {peer_id}: {cause:?}"); + } + SwarmEvent::Behaviour(CombinedEvent::PeerInfo(Event::Received { peer, info, .. })) => { + tracing::info!( + "📥 Received PeerInfo from {peer}:\n\ + │ Version: {}\n\ + │ Git Hash: {}\n\ + │ Nickname: {}\n\ + │ Builder API: {}\n\ + │ Lock Hash: {:?}", + info.charon_version, + info.git_hash, + info.nickname, + info.builder_api_enabled, + hex::encode(&info.lock_hash), + ); + } + SwarmEvent::Behaviour(CombinedEvent::PeerInfo(Event::Error { peer, error, .. })) => { + tracing::warn!("PeerInfo error with {peer}: {error}"); + } + SwarmEvent::Behaviour(CombinedEvent::Identify(identify::Event::Received { + peer_id, + info, + .. + })) => { + tracing::debug!( + "Identify received from {peer_id}: {} {}", + info.protocol_version, + info.agent_version + ); + } + SwarmEvent::Behaviour(CombinedEvent::Ping(ping::Event { peer, result, .. })) => { + match result { + Ok(rtt) => tracing::debug!("Ping to {peer}: {rtt:?}"), + Err(e) => tracing::debug!("Ping to {peer} failed: {e}"), + } + } + SwarmEvent::Behaviour(CombinedEvent::Mdns(mdns::Event::Discovered(peers))) => { + for (peer_id, addr) in peers { + tracing::info!("🔍 mDNS discovered peer {peer_id} at {addr}"); + if let Err(e) = swarm.dial(addr) { + tracing::warn!("Failed to dial discovered peer: {e}"); + } + } + } + SwarmEvent::Behaviour(CombinedEvent::Mdns(mdns::Event::Expired(peers))) => { + for (peer_id, addr) in peers { + tracing::debug!("mDNS peer expired: {peer_id} at {addr}"); + } + } + SwarmEvent::IncomingConnection { local_addr, .. } => { + tracing::debug!("Incoming connection on {local_addr}"); + } + _ => {} + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Initialize logging + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive("debug".parse()?)) + .init(); + + let args = Args::parse(); + + // Create local peer info + let local_info = LocalPeerInfo::new( + "v1.0.0", // charon_version + vec![0xDE, 0xAD, 0xBE, 0xEF], // lock_hash (example) + "abc1234", // git_hash + false, // builder_api_enabled + &args.nickname, // nickname + ); + + // Create peerinfo config with custom interval for demonstration + let peerinfo_config = Config::new(local_info) + .with_interval(Duration::from_secs(args.interval)) + .with_timeout(Duration::from_secs(10)); + + let mut swarm = build_swarm(peerinfo_config)?; + + let local_peer_id = *swarm.local_peer_id(); + tracing::info!("Local peer id: {local_peer_id}"); + tracing::info!("mDNS auto-discovery enabled"); + + // Listen on the specified port + let listen_addr: Multiaddr = format!("/ip4/0.0.0.0/tcp/{}", args.port).parse()?; + swarm.listen_on(listen_addr)?; + + // Dial the specified address if provided + if let Some(dial_addr) = &args.dial { + tracing::info!("Dialing {dial_addr}"); + swarm.dial(dial_addr.clone())?; + } + + tracing::info!( + "Peerinfo example started with nickname '{}', interval {}s", + args.nickname, + args.interval + ); + tracing::info!("Press Ctrl+C to exit"); + + // Main event loop + loop { + tokio::select! { + event = swarm.select_next_some() => handle_event(event, &mut swarm), + _ = signal::ctrl_c() => { + tracing::info!("Received Ctrl+C, shutting down..."); + break; + } + } + } + + Ok(()) +} diff --git a/crates/peerinfo/src/behaviour.rs b/crates/peerinfo/src/behaviour.rs new file mode 100644 index 00000000..666e3e3e --- /dev/null +++ b/crates/peerinfo/src/behaviour.rs @@ -0,0 +1,138 @@ +//! NetworkBehaviour implementation for the peerinfo protocol. +//! +//! This behaviour manages peer info exchanges across all connections, +//! emitting events when peer info is received from remote peers. + +use std::{ + collections::VecDeque, + task::{Context, Poll}, +}; + +use libp2p::{ + Multiaddr, PeerId, + swarm::{ + ConnectionDenied, ConnectionId, FromSwarm, NetworkBehaviour, THandler, THandlerInEvent, + THandlerOutEvent, ToSwarm, + }, +}; + +use crate::{ + Failure, + config::Config, + handler::{Handler, Success}, + peerinfopb::v1::peerinfo::PeerInfo, +}; + +/// Event emitted by the peerinfo behaviour. +#[derive(Debug, Clone)] +pub enum Event { + /// Received peer info from a remote peer. + Received { + /// The peer that sent the info. + peer: PeerId, + /// The connection on which the info was received. + connection: ConnectionId, + /// The peer info received. + info: PeerInfo, + }, + /// A peer info exchange failed. + Error { + /// The peer with which the exchange failed. + peer: PeerId, + /// The connection on which the exchange failed. + connection: ConnectionId, + /// The failure reason. + error: Failure, + }, +} + +/// Behaviour for the peerinfo protocol. +/// +/// This behaviour periodically exchanges peer info with connected peers +/// and emits events when peer info is received. +pub struct Behaviour { + /// Configuration for the behaviour. + config: Config, + /// Pending events to be emitted. + events: VecDeque, +} + +impl Behaviour { + /// Creates a new [`Behaviour`] with the given configuration. + pub fn new(config: Config) -> Self { + Self { + config, + events: VecDeque::new(), + } + } + + /// Returns the current configuration. + pub fn config(&self) -> &Config { + &self.config + } +} + +impl NetworkBehaviour for Behaviour { + type ConnectionHandler = Handler; + type ToSwarm = Event; + + fn handle_established_inbound_connection( + &mut self, + _connection_id: ConnectionId, + _peer: PeerId, + _local_addr: &Multiaddr, + _remote_addr: &Multiaddr, + ) -> Result, ConnectionDenied> { + Ok(Handler::new(self.config.clone())) + } + + fn handle_established_outbound_connection( + &mut self, + _connection_id: ConnectionId, + _peer: PeerId, + _addr: &Multiaddr, + _role_override: libp2p::core::Endpoint, + _port_use: libp2p::core::transport::PortUse, + ) -> Result, ConnectionDenied> { + Ok(Handler::new(self.config.clone())) + } + + fn on_swarm_event(&mut self, _event: FromSwarm) { + // No special handling needed for swarm events + } + + fn on_connection_handler_event( + &mut self, + peer_id: PeerId, + connection_id: ConnectionId, + event: THandlerOutEvent, + ) { + match event { + Ok(Success { peer_info }) => { + self.events.push_back(Event::Received { + peer: peer_id, + connection: connection_id, + info: peer_info, + }); + } + Err(failure) => { + self.events.push_back(Event::Error { + peer: peer_id, + connection: connection_id, + error: failure, + }); + } + } + } + + fn poll( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll>> { + if let Some(event) = self.events.pop_front() { + return Poll::Ready(ToSwarm::GenerateEvent(event)); + } + + Poll::Pending + } +} diff --git a/crates/peerinfo/src/config.rs b/crates/peerinfo/src/config.rs new file mode 100644 index 00000000..86faa208 --- /dev/null +++ b/crates/peerinfo/src/config.rs @@ -0,0 +1,125 @@ +//! Configuration for the peerinfo protocol. + +use std::time::Duration; + +use prost_types::Timestamp; + +use crate::peerinfopb::v1::peerinfo::PeerInfo; + +/// Default interval between peer info exchanges. +const DEFAULT_INTERVAL: Duration = Duration::from_secs(60); + +/// Default timeout for peer info requests. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(20); + +/// The configuration for the peerinfo protocol. +#[derive(Debug, Clone)] +pub struct Config { + /// The timeout for peer info requests. + pub(crate) timeout: Duration, + /// The interval between peer info exchanges. + pub(crate) interval: Duration, + /// Local peer info to send to other peers. + pub(crate) local_info: LocalPeerInfo, +} + +/// Local peer information to be shared with other peers. +#[derive(Debug, Clone, Default)] +pub struct LocalPeerInfo { + /// Charon version string (e.g., "v1.0.0"). + pub charon_version: String, + /// Lock hash identifying the cluster. + pub lock_hash: Vec, + /// Git commit hash (7 characters). + pub git_hash: String, + /// Whether the builder API is enabled. + pub builder_api_enabled: bool, + /// Human-readable nickname for this peer. + pub nickname: String, + /// Time when the node started. + pub started_at: Option, +} + +impl LocalPeerInfo { + /// Creates a new `LocalPeerInfo` with the given parameters. + pub fn new( + charon_version: impl Into, + lock_hash: impl Into>, + git_hash: impl Into, + builder_api_enabled: bool, + nickname: impl Into, + ) -> Self { + Self { + charon_version: charon_version.into(), + lock_hash: lock_hash.into(), + git_hash: git_hash.into(), + builder_api_enabled, + nickname: nickname.into(), + started_at: Some(Timestamp { + seconds: chrono::Utc::now().timestamp(), + nanos: 0, + }), + } + } + + /// Converts to a protobuf `PeerInfo` message with the current timestamp. + pub(crate) fn to_proto(&self) -> PeerInfo { + let now = chrono::Utc::now(); + PeerInfo { + charon_version: self.charon_version.clone(), + lock_hash: self.lock_hash.clone().into(), + git_hash: self.git_hash.clone(), + sent_at: Some(Timestamp { + seconds: now.timestamp(), + nanos: 0, + }), + started_at: self.started_at, + builder_api_enabled: self.builder_api_enabled, + nickname: self.nickname.clone(), + } + } +} + +impl Config { + /// Creates a new [`Config`] with the following default settings: + /// + /// * [`Config::with_interval`] 60s + /// * [`Config::with_timeout`] 20s + /// + /// These settings have the following effect: + /// + /// * A peer info request is sent every 60 seconds on a healthy connection. + /// * Every request must yield a response within 20 seconds to be + /// successful. + pub fn new(local_info: LocalPeerInfo) -> Self { + Self { + timeout: DEFAULT_TIMEOUT, + interval: DEFAULT_INTERVAL, + local_info, + } + } + + /// Sets the peer info request timeout. + pub fn with_timeout(mut self, d: Duration) -> Self { + self.timeout = d; + self + } + + /// Sets the peer info exchange interval. + pub fn with_interval(mut self, d: Duration) -> Self { + self.interval = d; + self + } + + /// Sets the local peer info. + pub fn with_local_info(mut self, info: LocalPeerInfo) -> Self { + self.local_info = info; + self + } +} + +impl Default for Config { + fn default() -> Self { + Self::new(LocalPeerInfo::default()) + } +} diff --git a/crates/peerinfo/src/failure.rs b/crates/peerinfo/src/failure.rs new file mode 100644 index 00000000..56859ff4 --- /dev/null +++ b/crates/peerinfo/src/failure.rs @@ -0,0 +1,63 @@ +//! Failure types for the peerinfo protocol. + +use std::{error::Error, fmt, sync::Arc}; + +/// A peer info exchange failure. +/// The difference between original `ping` implementation is that it's +/// cloneable. +#[derive(Debug, Clone)] +pub enum Failure { + /// The peer info request timed out, i.e., no response was received within + /// the configured timeout. + Timeout, + /// The peer does not support the peerinfo protocol. + Unsupported, + /// The peer info response was invalid (e.g., missing required fields). + InvalidResponse { + /// Description of the validation error. + reason: String, + }, + /// The peer info exchange failed for reasons other than a timeout. + Other { + /// The underlying error (wrapped in Arc for Clone). + error: Arc, + }, +} + +impl Failure { + /// Creates a new `Failure::Other` from any error type. + pub fn other(e: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::Other { error: Arc::new(e) } + } + + /// Creates a new `Failure::InvalidResponse` with the given reason. + pub fn invalid_response(reason: impl Into) -> Self { + Self::InvalidResponse { + reason: reason.into(), + } + } +} + +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Failure::Timeout => f.write_str("PeerInfo request timeout"), + Failure::Unsupported => f.write_str("PeerInfo protocol not supported"), + Failure::InvalidResponse { reason } => { + write!(f, "Invalid PeerInfo response: {reason}") + } + Failure::Other { error } => write!(f, "PeerInfo error: {error}"), + } + } +} + +impl Error for Failure { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Failure::Timeout => None, + Failure::Unsupported => None, + Failure::InvalidResponse { .. } => None, + Failure::Other { error } => Some(&**error), + } + } +} diff --git a/crates/peerinfo/src/handler.rs b/crates/peerinfo/src/handler.rs new file mode 100644 index 00000000..79f40fd5 --- /dev/null +++ b/crates/peerinfo/src/handler.rs @@ -0,0 +1,309 @@ +//! Connection handler for the peerinfo protocol. +//! +//! This handler manages peer info exchanges for a single connection, +//! periodically sending requests and handling incoming requests. +//! +//! The implementation uses libp2p::protocol::ping as a reference + +use std::{ + collections::VecDeque, + convert::Infallible, + task::{Context, Poll}, + time::Duration, +}; + +use futures::{future::BoxFuture, prelude::*}; +use futures_timer::Delay; +use libp2p::{ + core::upgrade::ReadyUpgrade, + swarm::{ + ConnectionHandler, ConnectionHandlerEvent, Stream, StreamProtocol, StreamUpgradeError, + SubstreamProtocol, + handler::{ + ConnectionEvent, DialUpgradeError, FullyNegotiatedInbound, FullyNegotiatedOutbound, + }, + }, +}; + +use crate::{ + PROTOCOL_NAME, config::Config, failure::Failure, peerinfopb::v1::peerinfo::PeerInfo, protocol, +}; + +/// Result of a successful peer info exchange. +#[derive(Debug, Clone)] +pub struct Success { + /// The peer info received from the remote peer. + pub peer_info: PeerInfo, +} + +/// Protocol handler that handles peer info exchange with a remote peer +/// at regular intervals and answers incoming peer info requests. +pub struct Handler { + /// Configuration options. + config: Config, + /// The timer used for the delay to the next request. + interval: Delay, + /// Outbound failures that are pending to be processed by `poll()`. + pending_errors: VecDeque, + /// The number of consecutive failures that occurred. + /// + /// Each successful exchange resets this counter to 0. + failures: u32, + /// The outbound request state. + outbound: Option, + /// The inbound response handler. + inbound: Option, + /// Tracks the state of our handler. + state: State, +} + +/// Tracks the state of the handler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// We are inactive because the other peer doesn't support peerinfo. + Inactive { + /// Whether or not we've reported the missing support yet. + reported: bool, + }, + /// We are actively exchanging peer info. + Active, +} + +impl Handler { + /// Builds a new [`Handler`] with the given configuration. + pub fn new(config: Config) -> Self { + let interval = config.interval; + Handler { + config, + interval: Delay::new(interval), + pending_errors: VecDeque::with_capacity(2), + failures: 0, + outbound: None, + inbound: None, + state: State::Active, + } + } + + fn on_dial_upgrade_error( + &mut self, + DialUpgradeError { error, .. }: DialUpgradeError< + (), + ::OutboundProtocol, + >, + ) { + self.outbound = None; // Request a new substream on the next `poll`. + + // Reset the timer to avoid issues with WASM timer implementation. + // See libp2p/rust-libp2p#5447 for more info. + self.interval.reset(Duration::new(0, 0)); + + let error = match error { + StreamUpgradeError::NegotiationFailed => { + debug_assert_eq!(self.state, State::Active); + self.state = State::Inactive { reported: false }; + return; + } + StreamUpgradeError::Timeout => Failure::other(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "peerinfo protocol negotiation timed out", + )), + StreamUpgradeError::Apply(e) => libp2p::core::util::unreachable(e), + StreamUpgradeError::Io(e) => Failure::other(e), + }; + + self.pending_errors.push_front(error); + } +} + +impl ConnectionHandler for Handler { + type FromBehaviour = Infallible; + type InboundOpenInfo = (); + type InboundProtocol = ReadyUpgrade; + type OutboundOpenInfo = (); + type OutboundProtocol = ReadyUpgrade; + type ToBehaviour = Result; + + fn listen_protocol(&self) -> SubstreamProtocol> { + SubstreamProtocol::new(ReadyUpgrade::new(PROTOCOL_NAME), ()) + } + + fn on_behaviour_event(&mut self, _: Infallible) {} + + #[tracing::instrument(level = "trace", name = "ConnectionHandler::poll", skip(self, cx))] + fn poll( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, (), Result>> + { + match self.state { + State::Inactive { reported: true } => { + return Poll::Pending; // Nothing to do on this connection + } + State::Inactive { reported: false } => { + self.state = State::Inactive { reported: true }; + return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour(Err( + Failure::Unsupported, + ))); + } + State::Active => {} + } + + // Handle inbound requests. + if let Some(fut) = self.inbound.as_mut() { + match fut.poll_unpin(cx) { + Poll::Pending => {} + Poll::Ready(Err(e)) => { + tracing::debug!("Inbound peerinfo error: {:?}", e); + self.inbound = None; + } + Poll::Ready(Ok((stream, _request))) => { + tracing::trace!("Answered inbound peerinfo request from peer"); + self.inbound = + Some(recv_peer_info(stream, self.config.local_info.to_proto()).boxed()); + } + } + } + + loop { + // Check for outbound failures. + if let Some(error) = self.pending_errors.pop_back() { + tracing::debug!("PeerInfo failure: {:?}", error); + + self.failures = self.failures.saturating_add(1); + + // For backward-compatibility, the first failure is "free" and silent. + // This allows peers using new substreams for each request to have + // successful exchanges with peers using a single substream. + if self.failures > 1 { + return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour(Err(error))); + } + } + + // Continue outbound requests. + match self.outbound.take() { + Some(OutboundState::Request(mut request)) => match request.poll_unpin(cx) { + Poll::Pending => { + self.outbound = Some(OutboundState::Request(request)); + break; + } + Poll::Ready(Ok((stream, peer_info))) => { + self.failures = 0; + self.interval.reset(self.config.interval); + self.outbound = Some(OutboundState::Idle(stream)); + return Poll::Ready(ConnectionHandlerEvent::NotifyBehaviour(Ok(Success { + peer_info, + }))); + } + Poll::Ready(Err(e)) => { + self.interval.reset(self.config.interval); + self.pending_errors.push_front(e); + } + }, + Some(OutboundState::Idle(stream)) => match self.interval.poll_unpin(cx) { + Poll::Pending => { + self.outbound = Some(OutboundState::Idle(stream)); + break; + } + Poll::Ready(_) => { + self.outbound = Some(OutboundState::Request( + send_peer_info( + stream, + self.config.local_info.to_proto(), + self.config.timeout, + ) + .boxed(), + )); + } + }, + Some(OutboundState::OpenStream) => { + self.outbound = Some(OutboundState::OpenStream); + break; + } + None => match self.interval.poll_unpin(cx) { + Poll::Pending => break, + Poll::Ready(()) => { + self.outbound = Some(OutboundState::OpenStream); + let protocol = SubstreamProtocol::new(ReadyUpgrade::new(PROTOCOL_NAME), ()); + return Poll::Ready(ConnectionHandlerEvent::OutboundSubstreamRequest { + protocol, + }); + } + }, + } + } + + Poll::Pending + } + + fn on_connection_event( + &mut self, + event: ConnectionEvent, + ) { + match event { + ConnectionEvent::FullyNegotiatedInbound(FullyNegotiatedInbound { + protocol: mut stream, + .. + }) => { + stream.ignore_for_keep_alive(); + let local_info = self.config.local_info.to_proto(); + self.inbound = Some(recv_peer_info(stream, local_info).boxed()); + } + ConnectionEvent::FullyNegotiatedOutbound(FullyNegotiatedOutbound { + protocol: mut stream, + .. + }) => { + stream.ignore_for_keep_alive(); + self.interval.reset(Duration::new(0, 0)); + let request = self.config.local_info.to_proto(); + self.outbound = Some(OutboundState::Request( + send_peer_info(stream, request, self.config.timeout).boxed(), + )); + } + ConnectionEvent::DialUpgradeError(dial_upgrade_error) => { + self.on_dial_upgrade_error(dial_upgrade_error) + } + _ => {} + } + } +} + +type RequestFuture = BoxFuture<'static, Result<(Stream, PeerInfo), Failure>>; +type InboundFuture = BoxFuture<'static, Result<(Stream, PeerInfo), std::io::Error>>; + +/// The current state w.r.t. outbound peer info requests. +/// +/// Note: We use a new stream for each request because the protocol uses +/// half-close pattern (stream is closed after each exchange). +enum OutboundState { + /// A new substream is being negotiated for the peerinfo protocol. + OpenStream, + /// The stream is idle and waiting for the next request. + Idle(Stream), + /// A request is being sent and the response awaited. + Request(RequestFuture), +} + +/// A wrapper around [`protocol::send_peer_info`] that enforces a timeout. +async fn send_peer_info( + stream: Stream, + request: PeerInfo, + timeout: Duration, +) -> Result<(Stream, PeerInfo), Failure> { + let send = protocol::send_peer_info(stream, &request); + futures::pin_mut!(send); + + match future::select(send, Delay::new(timeout)).await { + future::Either::Left((Ok((stream, response)), _)) => Ok((stream, response)), + future::Either::Left((Err(e), _)) => Err(Failure::other(e)), + future::Either::Right(((), _)) => Err(Failure::Timeout), + } +} + +/// A wrapper around [`protocol::recv_peer_info`] that returns only the stream +/// and request (for use in inbound handling). +async fn recv_peer_info( + stream: Stream, + local_info: PeerInfo, +) -> Result<(Stream, PeerInfo), std::io::Error> { + protocol::recv_peer_info(stream, &local_info).await +} diff --git a/crates/peerinfo/src/lib.rs b/crates/peerinfo/src/lib.rs new file mode 100644 index 00000000..742ad946 --- /dev/null +++ b/crates/peerinfo/src/lib.rs @@ -0,0 +1,67 @@ +//! # Charon Peerinfo +//! +//! The peerinfo protocol enables Charon nodes to exchange metadata about +//! themselves with connected peers. This includes version information, +//! cluster lock hash, git commit, builder API status, and nicknames. +//! +//! ## Protocol Overview +//! +//! The protocol works as a request-response pattern: +//! 1. Each peer periodically sends its own `PeerInfo` to connected peers +//! 2. The receiving peer responds with its own `PeerInfo` +//! 3. Both peers can use this information to verify compatibility and track +//! peer metadata +//! +//! ## Usage +//! +//! ```rust,ignore +//! use charon_peerinfo::{Behaviour, Config, LocalPeerInfo}; +//! +//! let local_info = LocalPeerInfo::new( +//! "v1.0.0", +//! vec![0u8; 32], // lock hash +//! "abc1234", // git hash +//! false, // builder API enabled +//! "my-node", // nickname +//! ); +//! +//! let config = Config::new(local_info) +//! .with_interval(Duration::from_secs(60)) +//! .with_timeout(Duration::from_secs(20)); +//! +//! let behaviour = Behaviour::new(config); +//! ``` + +use libp2p::swarm::StreamProtocol; + +/// Behaviour implementation for the peerinfo protocol. +pub mod behaviour; + +/// Configuration for the peerinfo protocol. +pub mod config; + +/// Failure types for the peerinfo protocol. +pub mod failure; + +/// Connection handler for the peerinfo protocol. +pub mod handler; + +/// Peerinfo protobuf definitions. +pub mod peerinfopb; + +/// Wire protocol implementation. +pub mod protocol; + +// Re-exports for convenience +pub use behaviour::{Behaviour, Event}; +pub use config::{Config, LocalPeerInfo}; +pub use failure::Failure; +pub use handler::Success; + +/// The protocol name for the peerinfo protocol (version 2.0.0). +pub const PROTOCOL_NAME: StreamProtocol = StreamProtocol::new("/charon/peerinfo/2.0.0"); + +/// Returns the supported protocols of this package in order of precedence. +pub fn protocols() -> Vec { + vec![PROTOCOL_NAME] +} diff --git a/crates/charon/src/peerinfo/peerinfopb/mod.rs b/crates/peerinfo/src/peerinfopb/mod.rs similarity index 100% rename from crates/charon/src/peerinfo/peerinfopb/mod.rs rename to crates/peerinfo/src/peerinfopb/mod.rs diff --git a/crates/charon/src/peerinfo/peerinfopb/v1.rs b/crates/peerinfo/src/peerinfopb/v1.rs similarity index 100% rename from crates/charon/src/peerinfo/peerinfopb/v1.rs rename to crates/peerinfo/src/peerinfopb/v1.rs diff --git a/crates/charon/src/peerinfo/peerinfopb/v1/peerinfo.proto b/crates/peerinfo/src/peerinfopb/v1/peerinfo.proto similarity index 100% rename from crates/charon/src/peerinfo/peerinfopb/v1/peerinfo.proto rename to crates/peerinfo/src/peerinfopb/v1/peerinfo.proto diff --git a/crates/charon/src/peerinfo/peerinfopb/v1/peerinfo.rs b/crates/peerinfo/src/peerinfopb/v1/peerinfo.rs similarity index 100% rename from crates/charon/src/peerinfo/peerinfopb/v1/peerinfo.rs rename to crates/peerinfo/src/peerinfopb/v1/peerinfo.rs diff --git a/crates/peerinfo/src/protocol.rs b/crates/peerinfo/src/protocol.rs new file mode 100644 index 00000000..333f67a2 --- /dev/null +++ b/crates/peerinfo/src/protocol.rs @@ -0,0 +1,93 @@ +//! Wire protocol implementation for the peerinfo protocol. +//! +//! This module handles encoding and decoding of PeerInfo messages on the wire +//! using the same format as Go's libp2p pbio package: +//! +//! ```text +//! [unsigned varint length][protobuf bytes] +//! ``` +//! +//! The unsigned varint encoding uses 7 bits per byte for data, with the MSB +//! as a continuation flag (1 = more bytes follow, 0 = last byte). + +use std::io; + +use futures::prelude::*; +use libp2p::swarm::Stream; +use prost::Message; +use unsigned_varint::aio::read_usize; + +use crate::peerinfopb::v1::peerinfo::PeerInfo; + +/// Maximum message size (64KB should be plenty for peer info). +const MAX_MESSAGE_SIZE: usize = 64 * 1024; + +/// Writes a protobuf message with unsigned varint length prefix to the stream. +/// +/// Wire format: `[uvarint length][protobuf bytes]` +async fn write_protobuf(stream: &mut Stream, msg: &M) -> io::Result<()> { + // Encode message to protobuf bytes + let mut buf = Vec::with_capacity(msg.encoded_len()); + msg.encode(&mut buf) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + + // Write unsigned varint length prefix + let mut len_buf = unsigned_varint::encode::usize_buffer(); + let encoded_len = unsigned_varint::encode::usize(buf.len(), &mut len_buf); + stream.write_all(encoded_len).await?; + + // Write protobuf bytes + stream.write_all(&buf).await?; + stream.flush().await +} + +/// Reads a protobuf message with unsigned varint length prefix from the stream. +/// +/// Wire format: `[uvarint length][protobuf bytes]` +/// +/// Returns an error if the message exceeds `MAX_MESSAGE_SIZE`. +async fn read_protobuf(stream: &mut Stream) -> io::Result { + // Read unsigned varint length prefix + let msg_len = read_usize(&mut *stream).await.map_err(|e| match e { + unsigned_varint::io::ReadError::Io(io_err) => io_err, + other => io::Error::new(io::ErrorKind::InvalidData, other), + })?; + + if msg_len > MAX_MESSAGE_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("message too large: {msg_len} bytes (max: {MAX_MESSAGE_SIZE})"), + )); + } + + // Read exactly `msg_len` protobuf bytes + let mut buf = vec![0u8; msg_len]; + stream.read_exact(&mut buf).await?; + + // Unmarshal protobuf + M::decode(&buf[..]).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} + +/// Sends a peer info request and waits for a response. +/// +/// Returns the response `PeerInfo` on success. +pub async fn send_peer_info( + mut stream: Stream, + request: &PeerInfo, +) -> io::Result<(Stream, PeerInfo)> { + write_protobuf(&mut stream, request).await?; + let response = read_protobuf(&mut stream).await?; + Ok((stream, response)) +} + +/// Receives a peer info request and sends a response. +/// +/// Returns the stream for potential reuse after successfully responding. +pub async fn recv_peer_info( + mut stream: Stream, + local_info: &PeerInfo, +) -> io::Result<(Stream, PeerInfo)> { + let request = read_protobuf(&mut stream).await?; + write_protobuf(&mut stream, local_info).await?; + Ok((stream, request)) +} From 351db0e498fdf09816d6e80c098cc15323eb3dd5 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Mon, 5 Jan 2026 16:16:48 +0200 Subject: [PATCH 22/24] fix: remove non-relevant comment --- crates/peerinfo/src/handler.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/peerinfo/src/handler.rs b/crates/peerinfo/src/handler.rs index 79f40fd5..79f20509 100644 --- a/crates/peerinfo/src/handler.rs +++ b/crates/peerinfo/src/handler.rs @@ -271,9 +271,6 @@ type RequestFuture = BoxFuture<'static, Result<(Stream, PeerInfo), Failure>>; type InboundFuture = BoxFuture<'static, Result<(Stream, PeerInfo), std::io::Error>>; /// The current state w.r.t. outbound peer info requests. -/// -/// Note: We use a new stream for each request because the protocol uses -/// half-close pattern (stream is closed after each exchange). enum OutboundState { /// A new substream is being negotiated for the peerinfo protocol. OpenStream, From ef8dae3641b6b796ec04ee61110911ee4a3a21e4 Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 20 Jan 2026 12:17:10 +0200 Subject: [PATCH 23/24] fix: move dependencies to workspace --- Cargo.toml | 3 +++ crates/peerinfo/Cargo.toml | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 885f5281..d0c7d938 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ cancellation = "0.1.0" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5.53", features = ["derive", "env", "cargo"] } crossbeam = "0.8.4" +futures = "0.3" +futures-timer = "3.0" backon = "1.6.0" hex = { version = "0.4.3" } prost = "0.14" @@ -50,6 +52,7 @@ tokio-util = "0.7.11" libp2p = { version = "0.56", features = ["full", "secp256k1"] } url = "2.5" uuid = { version = "1.19", features = ["serde", "v4"] } +unsigned-varint = { version = "0.8", features = ["futures"] } serde_with = { version = "3.16", features = ["hex", "base64"] } base64 = "0.22" sha3 = "0.10" diff --git a/crates/peerinfo/Cargo.toml b/crates/peerinfo/Cargo.toml index 7fb5d547..790d8692 100644 --- a/crates/peerinfo/Cargo.toml +++ b/crates/peerinfo/Cargo.toml @@ -11,11 +11,11 @@ prost.workspace = true prost-types.workspace = true thiserror.workspace = true libp2p.workspace = true -futures = "0.3" -futures-timer = "3.0" +futures.workspace = true +futures-timer.workspace = true tracing.workspace = true chrono.workspace = true -unsigned-varint = { version = "0.8", features = ["futures"] } +unsigned-varint.workspace = true [build-dependencies] charon-build-proto.workspace = true From e6944a1a65227521d967e8caaebb3222ad97b8ac Mon Sep 17 00:00:00 2001 From: Bohdan Ohorodnii <35969035+varex83@users.noreply.github.com> Date: Tue, 20 Jan 2026 13:13:22 +0200 Subject: [PATCH 24/24] feat: add compatibility tests --- Cargo.lock | 7 + Cargo.toml | 1 + crates/peerinfo/Cargo.toml | 3 +- crates/peerinfo/src/protocol.rs | 347 +++++++++++++++++++++++++++++++- 4 files changed, 355 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3945f13..32ba04bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1798,6 +1798,7 @@ dependencies = [ "futures", "futures-timer", "hex", + "hex-literal", "libp2p", "prost 0.14.3", "prost-types 0.14.3", @@ -3260,6 +3261,12 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + [[package]] name = "hex_fmt" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index d0c7d938..1cf0447f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ futures = "0.3" futures-timer = "3.0" backon = "1.6.0" hex = { version = "0.4.3" } +hex-literal = "0.4" prost = "0.14" prost-build = "0.14" prost-types = "0.14" diff --git a/crates/peerinfo/Cargo.toml b/crates/peerinfo/Cargo.toml index 790d8692..ae8e6a59 100644 --- a/crates/peerinfo/Cargo.toml +++ b/crates/peerinfo/Cargo.toml @@ -16,6 +16,7 @@ futures-timer.workspace = true tracing.workspace = true chrono.workspace = true unsigned-varint.workspace = true +hex.workspace = true [build-dependencies] charon-build-proto.workspace = true @@ -25,7 +26,7 @@ anyhow.workspace = true clap.workspace = true tokio.workspace = true tracing-subscriber.workspace = true -hex.workspace = true +hex-literal.workspace = true [lints] workspace = true diff --git a/crates/peerinfo/src/protocol.rs b/crates/peerinfo/src/protocol.rs index 333f67a2..5516b5a5 100644 --- a/crates/peerinfo/src/protocol.rs +++ b/crates/peerinfo/src/protocol.rs @@ -25,7 +25,10 @@ const MAX_MESSAGE_SIZE: usize = 64 * 1024; /// Writes a protobuf message with unsigned varint length prefix to the stream. /// /// Wire format: `[uvarint length][protobuf bytes]` -async fn write_protobuf(stream: &mut Stream, msg: &M) -> io::Result<()> { +async fn write_protobuf( + stream: &mut S, + msg: &M, +) -> io::Result<()> { // Encode message to protobuf bytes let mut buf = Vec::with_capacity(msg.encoded_len()); msg.encode(&mut buf) @@ -46,7 +49,9 @@ async fn write_protobuf(stream: &mut Stream, msg: &M) -> io::Result< /// Wire format: `[uvarint length][protobuf bytes]` /// /// Returns an error if the message exceeds `MAX_MESSAGE_SIZE`. -async fn read_protobuf(stream: &mut Stream) -> io::Result { +async fn read_protobuf( + stream: &mut S, +) -> io::Result { // Read unsigned varint length prefix let msg_len = read_usize(&mut *stream).await.map_err(|e| match e { unsigned_varint::io::ReadError::Io(io_err) => io_err, @@ -91,3 +96,341 @@ pub async fn recv_peer_info( write_protobuf(&mut stream, local_info).await?; Ok((stream, request)) } + +#[cfg(test)] +mod tests { + use super::*; + use hex_literal::hex; + + // Test case: minimal + // CharonVersion: "v1.0.0" + // LockHash: deadbeef + // BuilderApiEnabled: false + const PEERINFO_MINIMAL: &[u8] = &hex!("0a0676312e302e301204deadbeef"); + + // Test case: with_git_hash + // CharonVersion: "v1.7.1" + // LockHash: 0000000000000000000000000000000000000000000000000000000000000000 + // GitHash: "abc1234" + // BuilderApiEnabled: false + const PEERINFO_WITH_GIT_HASH: &[u8] = &hex!( + "0a0676312e372e3112200000000000000000000000000000000000000000000000000000000000000000220761626331323334" + ); + + // Test case: full + // CharonVersion: "v1.7.1" + // LockHash: 0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 + // SentAt: 2025-01-15T12:30:45Z + // GitHash: "a1b2c3d" + // StartedAt: 2025-01-15T10:00:00Z + // BuilderApiEnabled: true + // Nickname: "test-node" + const PEERINFO_FULL: &[u8] = &hex!( + "0a0676312e372e3112200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f201a0608f5d49ebc062207613162326333642a0608a08e9ebc0630013a09746573742d6e6f6465" + ); + + // Test case: builder_disabled + // CharonVersion: "v1.5.0" + // LockHash: ffffffff + // SentAt: 2024-12-01T00:00:00Z + // GitHash: "1234567" + // StartedAt: 2024-11-30T23:00:00Z + // BuilderApiEnabled: false + // Nickname: "validator-1" + const PEERINFO_BUILDER_DISABLED: &[u8] = &hex!( + "0a0676312e352e301204ffffffff1a060880ceaeba062207313233343536372a0608f0b1aeba063a0b76616c696461746f722d31" + ); + + // Test case: empty_optional_fields + // CharonVersion: "v1.6.0" + // LockHash: cafebabe + // BuilderApiEnabled: false + const PEERINFO_EMPTY_OPTIONAL_FIELDS: &[u8] = &hex!("0a0676312e362e301204cafebabe"); + + /// Helper to create a PeerInfo with minimal fields + fn make_minimal_peerinfo() -> PeerInfo { + PeerInfo { + charon_version: "v1.0.0".to_string(), + lock_hash: vec![0xde, 0xad, 0xbe, 0xef].into(), + sent_at: None, + git_hash: String::new(), + started_at: None, + builder_api_enabled: false, + nickname: String::new(), + } + } + + /// Helper to create a PeerInfo with git hash + fn make_with_git_hash_peerinfo() -> PeerInfo { + PeerInfo { + charon_version: "v1.7.1".to_string(), + lock_hash: vec![0u8; 32].into(), + sent_at: None, + git_hash: "abc1234".to_string(), + started_at: None, + builder_api_enabled: false, + nickname: String::new(), + } + } + + /// Helper to create a full PeerInfo with all fields + fn make_full_peerinfo() -> PeerInfo { + PeerInfo { + charon_version: "v1.7.1".to_string(), + lock_hash: (1u8..=32).collect::>().into(), + sent_at: Some(prost_types::Timestamp { + seconds: 1736944245, // 2025-01-15T13:00:45Z + nanos: 0, + }), + git_hash: "a1b2c3d".to_string(), + started_at: Some(prost_types::Timestamp { + seconds: 1736935200, // 2025-01-15T10:30:00Z + nanos: 0, + }), + builder_api_enabled: true, + nickname: "test-node".to_string(), + } + } + + /// Helper to create a PeerInfo with builder disabled + fn make_builder_disabled_peerinfo() -> PeerInfo { + PeerInfo { + charon_version: "v1.5.0".to_string(), + lock_hash: vec![0xff, 0xff, 0xff, 0xff].into(), + sent_at: Some(prost_types::Timestamp { + seconds: 1733011200, // 2024-12-01T00:00:00Z + nanos: 0, + }), + git_hash: "1234567".to_string(), + started_at: Some(prost_types::Timestamp { + seconds: 1733007600, // 2024-11-30T23:00:00Z + nanos: 0, + }), + builder_api_enabled: false, + nickname: "validator-1".to_string(), + } + } + + /// Helper to create a PeerInfo with empty optional fields + fn make_empty_optional_peerinfo() -> PeerInfo { + PeerInfo { + charon_version: "v1.6.0".to_string(), + lock_hash: vec![0xca, 0xfe, 0xba, 0xbe].into(), + sent_at: None, + git_hash: String::new(), + started_at: None, + builder_api_enabled: false, + nickname: String::new(), + } + } + + #[test] + fn test_decode_minimal() { + let decoded = PeerInfo::decode(PEERINFO_MINIMAL).unwrap(); + let expected = make_minimal_peerinfo(); + assert_eq!(decoded, expected); + } + + #[test] + fn test_decode_with_git_hash() { + let decoded = PeerInfo::decode(PEERINFO_WITH_GIT_HASH).unwrap(); + let expected = make_with_git_hash_peerinfo(); + assert_eq!(decoded, expected); + } + + #[test] + fn test_decode_full() { + let decoded = PeerInfo::decode(PEERINFO_FULL).unwrap(); + let expected = make_full_peerinfo(); + assert_eq!(decoded, expected); + } + + #[test] + fn test_decode_builder_disabled() { + let decoded = PeerInfo::decode(PEERINFO_BUILDER_DISABLED).unwrap(); + let expected = make_builder_disabled_peerinfo(); + assert_eq!(decoded, expected); + } + + #[test] + fn test_decode_empty_optional_fields() { + let decoded = PeerInfo::decode(PEERINFO_EMPTY_OPTIONAL_FIELDS).unwrap(); + let expected = make_empty_optional_peerinfo(); + assert_eq!(decoded, expected); + } + + #[test] + fn test_encode_minimal() { + let msg = make_minimal_peerinfo(); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!(buf, PEERINFO_MINIMAL); + } + + #[test] + fn test_encode_with_git_hash() { + let msg = make_with_git_hash_peerinfo(); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!(buf, PEERINFO_WITH_GIT_HASH); + } + + #[test] + fn test_encode_full() { + let msg = make_full_peerinfo(); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!(buf, PEERINFO_FULL); + } + + #[test] + fn test_encode_builder_disabled() { + let msg = make_builder_disabled_peerinfo(); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!(buf, PEERINFO_BUILDER_DISABLED); + } + + #[test] + fn test_encode_empty_optional_fields() { + let msg = make_empty_optional_peerinfo(); + let mut buf = Vec::new(); + msg.encode(&mut buf).unwrap(); + assert_eq!(buf, PEERINFO_EMPTY_OPTIONAL_FIELDS); + } + + #[test] + fn test_roundtrip_all_variants() { + let variants = [ + make_minimal_peerinfo(), + make_with_git_hash_peerinfo(), + make_full_peerinfo(), + make_builder_disabled_peerinfo(), + make_empty_optional_peerinfo(), + ]; + + for original in variants { + let mut buf = Vec::new(); + original.encode(&mut buf).unwrap(); + let decoded = PeerInfo::decode(&buf[..]).unwrap(); + assert_eq!(original, decoded); + } + } + + #[tokio::test] + async fn test_write_read_protobuf_minimal() { + let original = make_minimal_peerinfo(); + + // Write to a cursor + let mut buf = Vec::new(); + write_protobuf(&mut buf, &original).await.unwrap(); + + // The wire format should be: [varint length][protobuf bytes] + // Minimal message is 14 bytes, so length prefix is just 1 byte (14 < 128) + assert_eq!(buf[0] as usize, PEERINFO_MINIMAL.len()); + assert_eq!(&buf[1..], PEERINFO_MINIMAL); + + // Read it back + let mut cursor = futures::io::Cursor::new(&buf[..]); + let decoded: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + assert_eq!(original, decoded); + } + + #[tokio::test] + async fn test_write_read_protobuf_full() { + let original = make_full_peerinfo(); + + let mut buf = Vec::new(); + write_protobuf(&mut buf, &original).await.unwrap(); + + // Read it back + let mut cursor = futures::io::Cursor::new(&buf[..]); + let decoded: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + assert_eq!(original, decoded); + } + + #[tokio::test] + async fn test_write_read_protobuf_all_variants() { + let variants = [ + make_minimal_peerinfo(), + make_with_git_hash_peerinfo(), + make_full_peerinfo(), + make_builder_disabled_peerinfo(), + make_empty_optional_peerinfo(), + ]; + + for original in variants { + let mut buf = Vec::new(); + write_protobuf(&mut buf, &original).await.unwrap(); + + let mut cursor = futures::io::Cursor::new(&buf[..]); + let decoded: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + assert_eq!(original, decoded); + } + } + + #[tokio::test] + async fn test_read_protobuf_message_too_large() { + // Create a buffer with a length prefix that exceeds MAX_MESSAGE_SIZE + let mut buf = Vec::new(); + let large_len = MAX_MESSAGE_SIZE + 1; + let mut len_buf = unsigned_varint::encode::usize_buffer(); + let encoded_len = unsigned_varint::encode::usize(large_len, &mut len_buf); + buf.extend_from_slice(encoded_len); + + let mut cursor = futures::io::Cursor::new(&buf[..]); + let result: io::Result = read_protobuf(&mut cursor).await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("message too large")); + } + + #[tokio::test] + async fn test_read_protobuf_invalid_data() { + // Create a buffer with valid length but invalid protobuf data + let invalid_data = [0x05, 0xff, 0xff, 0xff, 0xff, 0xff]; // length 5, then garbage + + let mut cursor = futures::io::Cursor::new(&invalid_data[..]); + let result: io::Result = read_protobuf(&mut cursor).await; + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData); + } + + #[tokio::test] + async fn test_read_protobuf_truncated_message() { + // Create a buffer that claims a length but doesn't have enough bytes + let truncated = [0x10]; // claims 16 bytes but has none + + let mut cursor = futures::io::Cursor::new(&truncated[..]); + let result: io::Result = read_protobuf(&mut cursor).await; + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::UnexpectedEof); + } + + #[tokio::test] + async fn test_multiple_messages_in_stream() { + let msg1 = make_minimal_peerinfo(); + let msg2 = make_full_peerinfo(); + let msg3 = make_with_git_hash_peerinfo(); + + // Write multiple messages to the same buffer + let mut buf = Vec::new(); + write_protobuf(&mut buf, &msg1).await.unwrap(); + write_protobuf(&mut buf, &msg2).await.unwrap(); + write_protobuf(&mut buf, &msg3).await.unwrap(); + + // Read them back in order + let mut cursor = futures::io::Cursor::new(&buf[..]); + let decoded1: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + let decoded2: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + let decoded3: PeerInfo = read_protobuf(&mut cursor).await.unwrap(); + + assert_eq!(msg1, decoded1); + assert_eq!(msg2, decoded2); + assert_eq!(msg3, decoded3); + } +}