From c3f7713965b6dcf38e6e64821f9fc0fa30567445 Mon Sep 17 00:00:00 2001 From: Rayhaan Jaufeerally Date: Wed, 16 Sep 2026 23:10:16 +0200 Subject: [PATCH] feature: HTTP API, event stream and Prometheus metrics (bgp-web) Phase 2 of the teaching demo: everything the status page will need is now reachable over HTTP. - New crate crates/web (axum). JSON endpoints for status, peers, local routes (announce / withdraw), received routes, the exact UPDATE bytes a local route produces per session, and well-known community names. Communities are accepted as ASN:VALUE or by name (NO_EXPORT, BLACKHOLE, ...); large communities as A:B:C. Policy errors map to 403/409/422. - GET /api/events is a server-sent event stream merging session up/down, local route and Adj-RIB-In changes, with lag notifications. - GET /metricz serves Prometheus metrics; the server crate now counts messages sent/received by type and sessions established. - The runnable main moves from crates/server to bin/bgp so it can depend on both server and web without a cycle. `cargo run -- config.textpb`. - Config gains http_endpoint; unset disables the API. - Integration tests drive the API against a live server with a real BGP peer, including verifying the wire view matches the bytes the peer got. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 871 ++++++++++++++++++-- Cargo.toml | 4 + bin/bgp/Cargo.toml | 9 +- bin/bgp/README.md | 47 +- bin/bgp/src/main.rs | 55 +- crates/integration-tests/Cargo.toml | 3 + crates/integration-tests/src/test_server.rs | 40 +- crates/integration-tests/tests/http_api.rs | 298 +++++++ crates/server/proto/config.proto | 4 + crates/server/src/config.rs | 13 + crates/server/src/fsm.rs | 4 + crates/server/src/lib.rs | 1 + crates/server/src/main.rs | 29 - crates/server/src/metrics.rs | 59 ++ crates/server/src/server.rs | 5 + crates/server/src/session.rs | 4 + crates/server/src/tracing.rs | 2 +- crates/web/Cargo.toml | 23 + crates/web/src/api.rs | 396 +++++++++ crates/web/src/community.rs | 80 ++ crates/web/src/events.rs | 90 ++ crates/web/src/lib.rs | 76 ++ crates/web/src/metrics.rs | 21 + testdata/server.textpb | 4 + 24 files changed, 2009 insertions(+), 129 deletions(-) create mode 100644 crates/integration-tests/tests/http_api.rs delete mode 100644 crates/server/src/main.rs create mode 100644 crates/server/src/metrics.rs create mode 100644 crates/web/Cargo.toml create mode 100644 crates/web/src/api.rs create mode 100644 crates/web/src/community.rs create mode 100644 crates/web/src/events.rs create mode 100644 crates/web/src/lib.rs create mode 100644 crates/web/src/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 9b0d826..40f181c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - [[package]] name = "aho-corasick" version = "1.1.4" @@ -82,6 +67,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -89,26 +80,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] -name = "backtrace" -version = "0.3.74" +name = "axum" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bgp" version = "0.0.2" dependencies = [ + "bgp-server", + "bgp-web", + "clap", "eyre", + "protobuf", "tokio", + "tracing", ] [[package]] @@ -119,8 +164,11 @@ dependencies = [ "bgp-primitives", "bgp-rib", "bgp-server", + "bgp-web", "bytes", "eyre", + "reqwest", + "serde_json", "tokio", ] @@ -185,6 +233,27 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "bgp-web" +version = "0.0.0" +dependencies = [ + "axum", + "bgp-packet", + "bgp-primitives", + "bgp-rib", + "bgp-server", + "bytes", + "eyre", + "futures", + "prometheus", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "bitfield" version = "0.19.0" @@ -202,7 +271,7 @@ checksum = "07805405d3f1f3a55aab895718b488821d40458f9188059909091ae0935c344a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -211,6 +280,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.10.1" @@ -254,7 +329,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -269,6 +344,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "either" version = "1.15.0" @@ -313,6 +399,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.32" @@ -369,7 +464,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -413,12 +508,6 @@ dependencies = [ "wasip2", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "hashbrown" version = "0.16.1" @@ -440,6 +529,199 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indenter" version = "0.3.3" @@ -456,12 +738,35 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -470,9 +775,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.171" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -486,6 +791,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "lock_api" version = "0.4.12" @@ -511,6 +822,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.4" @@ -518,23 +835,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] -name = "miniz_oxide" -version = "0.8.8" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.0.3" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -555,15 +869,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.1" @@ -599,12 +904,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.94" @@ -682,9 +1002,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -734,10 +1054,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "reqwest" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "rustix" @@ -771,6 +1116,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -779,22 +1130,56 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", ] [[package]] @@ -805,7 +1190,19 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -840,14 +1237,20 @@ checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" -version = "0.5.8" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -873,7 +1276,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.100", ] [[package]] @@ -887,6 +1290,37 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +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" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -926,7 +1360,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -937,7 +1371,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -950,12 +1384,21 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.44.2" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "backtrace", "bytes", "libc", "mio", @@ -964,26 +1407,98 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -997,7 +1512,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -1039,12 +1554,36 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1057,6 +1596,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1072,6 +1620,71 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "which" version = "4.4.2" @@ -1090,15 +1703,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -1186,3 +1790,92 @@ name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index cefb67f..ed88546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ bgp-packet = { path = "crates/packet" } bgp-primitives = { path = "crates/primitives" } bgp-rib = { path = "crates/rib" } bgp-server = { path = "crates/server" } +bgp-web = { path = "crates/web" } bgp-integration-tests = { path = "crates/integration-tests" } # --- General --- @@ -39,3 +40,6 @@ protobuf = { package = "protobuf", version = "3.7.2" } protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" } clap = { version = "4.5.53", features = ["derive"] } futures = "0.3" +axum = "0.8.9" +tokio-stream = { version = "0.1.19", features = ["sync"] } +reqwest = { version = "0.13.5", default-features = false, features = ["json"] } diff --git a/bin/bgp/Cargo.toml b/bin/bgp/Cargo.toml index b149682..c1bf949 100644 --- a/bin/bgp/Cargo.toml +++ b/bin/bgp/Cargo.toml @@ -7,5 +7,10 @@ name = "bgp" version.workspace = true [dependencies] -eyre.workspace = true -tokio.workspace = true +bgp-server.workspace = true +bgp-web.workspace = true +clap.workspace = true +eyre.workspace = true +protobuf.workspace = true +tokio.workspace = true +tracing.workspace = true diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 138be7f..647074b 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -1,9 +1,48 @@ # BGP -This crate provides a binary which runs the Border Gateway Protocol (BGP). +This crate provides the `bgp` binary which runs the Border Gateway Protocol +daemon together with its HTTP API. -Please refer to the other implementation crates part of this repository where most of the actual implemntation lives. +``` +cargo run -- path/to/config.textpb +``` -Warning: This is still very much a work in progress and is not yet ready for use. +The configuration format is defined in `crates/server/proto/config.proto`; +`testdata/server.textpb` is a worked example. When `http_endpoint` is set the +binary also serves: -The main ongoing work is happening currently in the BGP packet crate. +| Path | Purpose | +|---|---| +| `GET /api/status` | Router ID, ASN, uptime and table sizes | +| `GET /api/peers` | Established sessions | +| `GET /api/routes/local` | Locally originated routes | +| `POST /api/routes/local` | Announce a route (JSON body, see below) | +| `DELETE /api/routes/local` | Withdraw a route (JSON body: `prefix`, optional `owner`) | +| `GET /api/routes/local/wire?prefix=…` | The exact UPDATE bytes sent to each peer | +| `GET /api/routes/received` | Adj-RIB-In contents | +| `GET /api/communities/well-known` | Names accepted in place of numeric communities | +| `GET /api/events` | Server-sent events for session and route changes | +| `GET /metricz` | Prometheus metrics | + +Announce body: + +```json +{ + "prefix": "10.100.1.0/24", + "owner": "alice", + "origin": "IGP", + "prepend": 2, + "med": 100, + "communities": ["65000:100", "NO_EXPORT"], + "large_communities": ["65000:1:2"] +} +``` + +Only `prefix` and `owner` are required. Routes are subject to the +`local_route_policy` in the configuration. + +Please refer to the other implementation crates in this repository where most +of the actual implementation lives. + +Warning: This is still very much a work in progress and is not yet ready for +production use. diff --git a/bin/bgp/src/main.rs b/bin/bgp/src/main.rs index d3f238b..9eb5c5f 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -1,6 +1,55 @@ -use eyre::Result; +use std::sync::Arc; +use std::time::Instant; + +use bgp_server::config::Config; +use bgp_server::proto::config::BgpConfig; +use bgp_server::server::BgpServerBuilder; +use bgp_server::tracing::TracingArgs; +use bgp_web::{AppState, RouterInfo}; +use clap::Parser; +use eyre::{Context, Result}; +use tracing::error; + +#[derive(Parser, Debug)] +struct ServerArgs { + /// Path to the textproto configuration file. + config: String, + + #[command(flatten)] + tracing: TracingArgs, +} #[tokio::main] -pub async fn main() -> Result<()> { - todo!("Implement the server"); +async fn main() -> Result<()> { + let args = ServerArgs::parse(); + args.tracing.install()?; + + let config_string = + std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?; + let proto_config = protobuf::text_format::parse_from_str::(&config_string) + .wrap_err("Failed to parse config file")?; + let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?; + + let server = BgpServerBuilder::new(config).bind().await?; + + if let Some(http_endpoint) = server.config().http_endpoint { + let state = AppState { + info: Arc::new(RouterInfo { + router_id: server.config().router_id, + local_asn: server.config().local_asn, + version: env!("CARGO_PKG_VERSION").to_string(), + started: Instant::now(), + }), + registry: server.registry.clone(), + local_rib: server.local_rib.clone(), + adj_rib_in: server.adj_rib_in.clone(), + }; + tokio::spawn(async move { + if let Err(e) = bgp_web::serve(state, http_endpoint).await { + error!(err = %e, "HTTP server exited"); + } + }); + } + + server.run().await } diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 8a08c69..407fdb1 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -8,6 +8,9 @@ bgp-packet.workspace = true bgp-primitives.workspace = true bgp-rib.workspace = true bgp-server.workspace = true +bgp-web.workspace = true bytes.workspace = true eyre.workspace = true +reqwest.workspace = true +serde_json.workspace = true tokio.workspace = true diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index 139db1d..7d00a4d 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -4,7 +4,9 @@ use bgp_rib::{AdjRibIn, LocalRib, LocalRibPolicy}; use bgp_server::config::Config; use bgp_server::server::BgpServerBuilder; use bgp_server::session::SessionRegistry; +use bgp_web::{AppState, RouterInfo}; use eyre::Result; +use tokio::net::TcpListener; use tokio::task::JoinHandle; /// A BGP server started on an ephemeral port for testing. @@ -19,6 +21,7 @@ pub struct TestServer { pub local_rib: LocalRib, /// Live view of the server's established sessions. pub registry: SessionRegistry, + pub local_asn: u32, } impl TestServer { @@ -45,6 +48,7 @@ impl TestServer { listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)], peers: vec![], local_route_policy: policy, + http_endpoint: None, }; let bound = BgpServerBuilder::new(config).bind().await?; @@ -58,7 +62,41 @@ impl TestServer { let registry = bound.registry.clone(); let handle = tokio::spawn(bound.run()); - Ok(Self { handle, addr, adj_rib_in, local_rib, registry }) + Ok(Self { handle, addr, adj_rib_in, local_rib, registry, local_asn }) + } +} + +impl TestServer { + /// Starts the HTTP API on an ephemeral loopback port and returns its + /// base URL (e.g. `http://127.0.0.1:12345`). The API task is aborted + /// when the returned guard drops. + pub async fn serve_http(&self) -> Result<(String, HttpGuard)> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let base_url = format!("http://{}", listener.local_addr()?); + let state = AppState { + info: std::sync::Arc::new(RouterInfo { + router_id: Ipv4Addr::new(127, 0, 0, 1), + local_asn: self.local_asn, + version: "test".into(), + started: std::time::Instant::now(), + }), + registry: self.registry.clone(), + local_rib: self.local_rib.clone(), + adj_rib_in: self.adj_rib_in.clone(), + }; + let handle = tokio::spawn(bgp_web::serve_on(state, listener)); + Ok((base_url, HttpGuard { handle })) + } +} + +/// Aborts the HTTP API task when dropped. +pub struct HttpGuard { + handle: JoinHandle>, +} + +impl Drop for HttpGuard { + fn drop(&mut self) { + self.handle.abort(); } } diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs new file mode 100644 index 0000000..6f5d32b --- /dev/null +++ b/crates/integration-tests/tests/http_api.rs @@ -0,0 +1,298 @@ +//! Drives the HTTP API against a real server with a real BGP peer attached. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::message::{ + AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, + OriginPathAttribute, PathAttribute, UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_primitives::parser::ParserContext; +use bgp_rib::LocalRibPolicy; +use eyre::Result; +use serde_json::{Value, json}; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +async fn established_peer(server: &TestServer) -> Result { + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(peer) +} + +async fn get_json(client: &reqwest::Client, url: &str) -> Result { + Ok(client.get(url).send().await?.error_for_status()?.json().await?) +} + +#[tokio::test] +async fn test_status_and_peers() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let status = get_json(&client, &format!("{base}/api/status")).await?; + assert_eq!(status["local_asn"], LOCAL_ASN); + assert_eq!(status["router_id"], "127.0.0.1"); + assert_eq!(status["sessions"], 0); + + let _peer = established_peer(&server).await?; + + let status = get_json(&client, &format!("{base}/api/status")).await?; + assert_eq!(status["sessions"], 1); + + let peers = get_json(&client, &format!("{base}/api/peers")).await?; + assert_eq!(peers.as_array().unwrap().len(), 1); + assert_eq!(peers[0]["peer_asn"], PEER_ASN); + assert_eq!(peers[0]["local_addr"], server.addr.to_string()); + assert_eq!(peers[0]["four_octet_asn"], true); + Ok(()) +} + +/// The full loop: POST an announcement, the BGP peer receives the UPDATE, +/// the local table lists it, the wire view matches the bytes, DELETE +/// withdraws it. +#[tokio::test] +async fn test_announce_over_http_reaches_peer() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + let mut peer = established_peer(&server).await?; + + let resp = client + .post(format!("{base}/api/routes/local")) + .json(&json!({ + "prefix": "10.100.1.0/24", + "owner": "alice", + "prepend": 1, + "med": 10, + "communities": ["65000:100", "NO_EXPORT"], + "large_communities": ["65000:1:2"], + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let body: Value = resp.json().await?; + assert_eq!(body["prefix"], "10.100.1.0/24"); + assert_eq!(body["origin"], "IGP"); + assert_eq!(body["communities"], json!(["65000:100", "65535:65281"])); + assert_eq!(body["large_communities"], json!(["65000:1:2"])); + + // The peer sees the UPDATE. + let update: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); + assert_eq!(update.path_attributes[1], AsPathAttribute::from_asns(vec![LOCAL_ASN, LOCAL_ASN])); + assert_eq!(update.path_attributes[2], PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST))); + assert!(update.path_attributes.contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![ + (65000, 100), + (65535, 65281) + ])))); + + // Local table lists it. + let local = get_json(&client, &format!("{base}/api/routes/local")).await?; + assert_eq!(local.as_array().unwrap().len(), 1); + assert_eq!(local[0]["owner"], "alice"); + + // Wire view reproduces exactly what went over the socket. + let wire = get_json(&client, &format!("{base}/api/routes/local/wire?prefix=10.100.1.0/24")).await?; + assert_eq!(wire.as_array().unwrap().len(), 1); + assert_eq!(wire[0]["peer_asn"], PEER_ASN); + assert_eq!(wire[0]["summary"]["as_path"], json!([LOCAL_ASN, LOCAL_ASN])); + let hex = wire[0]["hex"].as_str().unwrap(); + let ctx = ParserContext { four_octet_asn: peer.ctx.four_octet_asn, address_family: None }; + let mut expected = bytes::BytesMut::new(); + Message::Update(update).to_wire(&ctx, &mut expected)?; + let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, expected_hex); + assert!(hex.starts_with(&"ff".repeat(16)), "marker missing"); + + // Withdraw. + let resp = client + .delete(format!("{base}/api/routes/local")) + .json(&json!({ "prefix": "10.100.1.0/24", "owner": "alice" })) + .send() + .await?; + assert_eq!(resp.status(), 200); + let withdrawn: UpdateMessage = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(withdrawn.withdrawn_routes, vec![prefix("10.100.1.0/24")]); + + let local = get_json(&client, &format!("{base}/api/routes/local")).await?; + assert!(local.as_array().unwrap().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn test_validation_and_policy_status_codes() -> Result<()> { + let server = TestServer::start_with_policy( + LOCAL_ASN, + LocalRibPolicy { allowed_prefixes: vec![prefix("10.100.0.0/16")], max_routes_per_owner: 1 }, + ) + .await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + let url = format!("{base}/api/routes/local"); + + let post = |body: Value| client.post(&url).json(&body).send(); + + // Malformed input → 400 with an error message. + let resp = post(json!({ "prefix": "not-a-prefix", "owner": "alice" })).await?; + assert_eq!(resp.status(), 400); + assert!(resp.json::().await?["error"].as_str().unwrap().contains("invalid prefix")); + + let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "" })).await?; + assert_eq!(resp.status(), 400); + + let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "communities": ["bogus"] })).await?; + assert_eq!(resp.status(), 400); + + let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "alice", "origin": "weird" })).await?; + assert_eq!(resp.status(), 400); + + // Outside allowlist → 403. + let resp = post(json!({ "prefix": "192.0.2.0/24", "owner": "alice" })).await?; + assert_eq!(resp.status(), 403); + + // First allowed route is fine. + assert_eq!(post(json!({ "prefix": "10.100.1.0/24", "owner": "alice" })).await?.status(), 200); + + // Per-owner limit → 422. + let resp = post(json!({ "prefix": "10.100.2.0/24", "owner": "alice" })).await?; + assert_eq!(resp.status(), 422); + + // Someone else's prefix → 409, for both replace and withdraw. + let resp = post(json!({ "prefix": "10.100.1.0/24", "owner": "bob" })).await?; + assert_eq!(resp.status(), 409); + let resp = client + .delete(&url) + .json(&json!({ "prefix": "10.100.1.0/24", "owner": "bob" })) + .send() + .await?; + assert_eq!(resp.status(), 409); + + // Withdrawing an unknown prefix is a 200 with null. + let resp = client.delete(&url).json(&json!({ "prefix": "10.100.7.0/24" })).send().await?; + assert_eq!(resp.status(), 200); + assert_eq!(resp.json::().await?, Value::Null); + + // Wire view for an unannounced prefix → 404. + let resp = client.get(format!("{base}/api/routes/local/wire?prefix=10.100.7.0/24")).send().await?; + assert_eq!(resp.status(), 404); + Ok(()) +} + +/// Routes the peer sends us show up in the received table with a summary. +#[tokio::test] +async fn test_received_routes_summary() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + let mut peer = established_peer(&server).await?; + + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![PEER_ASN, 65002]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))), + PathAttribute::Communities(CommunitiesPathAttribute(vec![(65001, 7)])), + ], + announced_routes: vec![prefix("203.0.113.0/24")], + })) + .await?; + + let received = tokio::time::timeout(TIMEOUT, async { + loop { + let v = get_json(&client, &format!("{base}/api/routes/received")).await?; + if !v.as_array().unwrap().is_empty() { + return Ok::<_, eyre::Error>(v); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await??; + assert_eq!(received[0]["prefix"], "203.0.113.0/24"); + assert_eq!(received[0]["peer_asn"], PEER_ASN); + assert_eq!(received[0]["summary"]["as_path"], json!([PEER_ASN, 65002])); + assert_eq!(received[0]["summary"]["next_hop"], "10.0.0.1"); + assert_eq!(received[0]["summary"]["communities"], json!(["65001:7"])); + Ok(()) +} + +/// The SSE stream reports session and route changes as they happen. +#[tokio::test] +async fn test_event_stream() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let mut resp = client.get(format!("{base}/api/events")).send().await?.error_for_status()?; + assert!(resp.headers()["content-type"].to_str()?.starts_with("text/event-stream")); + + let _peer = established_peer(&server).await?; + server + .local_rib + .announce(bgp_rib::LocalRoute::new(prefix("10.100.1.0/24"), "alice", Default::default())) + .await?; + + // Collect SSE frames until we have seen both events. + let mut buf = String::new(); + let mut seen_up = false; + let mut seen_announce = false; + tokio::time::timeout(TIMEOUT, async { + while !(seen_up && seen_announce) { + let chunk = resp.chunk().await?.ok_or_else(|| eyre::eyre!("stream ended"))?; + buf.push_str(std::str::from_utf8(&chunk)?); + for line in buf.lines().filter_map(|l| l.strip_prefix("data:")) { + let ev: Value = serde_json::from_str(line.trim())?; + match ev["type"].as_str() { + Some("session_up") if ev["peer"]["peer_asn"] == PEER_ASN => seen_up = true, + Some("local_announced") if ev["route"]["prefix"] == "10.100.1.0/24" => seen_announce = true, + _ => {} + } + } + } + Ok::<_, eyre::Error>(()) + }) + .await??; + Ok(()) +} + +#[tokio::test] +async fn test_metricz_and_well_known() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + let _peer = established_peer(&server).await?; + + let text = client.get(format!("{base}/metricz")).send().await?.error_for_status()?.text().await?; + // Metrics are process-global, so other tests in this binary contribute + // too; assert on presence and a lower bound rather than exact values. + let metric = |name: &str| -> Option { + text.lines() + .find(|l| l.starts_with(name) && l[name.len()..].starts_with(' ')) + .and_then(|l| l.rsplit(' ').next()?.parse().ok()) + }; + assert!(metric("bgp_sessions_established").unwrap_or(0) >= 1, "{text}"); + assert!(metric("bgp_messages_sent_total{type=\"open\"}").unwrap_or(0) >= 1, "{text}"); + assert!(metric("bgp_messages_received_total{type=\"keepalive\"}").unwrap_or(0) >= 1, "{text}"); + assert!(metric("bgp_local_routes").is_some(), "{text}"); + assert!(metric("bgp_adj_rib_in_routes").is_some(), "{text}"); + + let wk = get_json(&client, &format!("{base}/api/communities/well-known")).await?; + assert!(wk.as_array().unwrap().iter().any(|c| c["name"] == "NO_EXPORT" && c["value"] == "65535:65281")); + Ok(()) +} diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 37fc5ab..b3f419c 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -20,6 +20,10 @@ message BgpConfig { // Limits on routes originated locally (e.g. via the demo UI). LocalRoutePolicy local_route_policy = 5; + + // Where to serve the HTTP API, status page and metrics. The port is + // required. Unset disables the HTTP server. + TcpEndpoint http_endpoint = 6; } // Limits on locally originated routes. diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index d15f872..a2fd197 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -20,6 +20,8 @@ pub struct Config { pub peers: Vec, /// Limits on locally originated routes. pub local_route_policy: LocalRibPolicy, + /// Address for the HTTP API; `None` disables it. + pub http_endpoint: Option, } pub struct PeerConfig { @@ -90,6 +92,16 @@ impl Config { None => LocalRibPolicy::default(), }; + let http_endpoint = match proto.http_endpoint.into_option() { + Some(ep) => { + if ep.port == 0 { + bail!("http_endpoint.port is required"); + } + Some(parse_endpoint(&ep.address, ep.port).wrap_err("Invalid http_endpoint")?) + } + None => None, + }; + Ok(Self { router_id, local_asn: proto.local_asn, @@ -97,6 +109,7 @@ impl Config { listen_endpoints, peers, local_route_policy, + http_endpoint, }) } } diff --git a/crates/server/src/fsm.rs b/crates/server/src/fsm.rs index d9b24ab..52189a4 100644 --- a/crates/server/src/fsm.rs +++ b/crates/server/src/fsm.rs @@ -18,6 +18,7 @@ use tracing::{info, warn}; use bgp_packet::message::{AS_TRANS, BGP4_VERSION}; +use crate::metrics::{MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, message_type_name}; use crate::session::{SessionHandle, SessionRegistry}; /// Number of outbound messages that may be queued for a session before @@ -141,6 +142,7 @@ pub async fn run_session( } info!(%peer_addr, peer_asn, "Session established"); + SESSIONS_ESTABLISHED_TOTAL.inc(); let peer_id = PeerId { addr: peer_addr, asn: peer_asn }; @@ -266,6 +268,7 @@ async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result let (_, msg) = Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?; + MESSAGES_RECEIVED.with_label_values(&[message_type_name(&msg)]).inc(); Ok(msg) } @@ -287,6 +290,7 @@ async fn send_message( let mut buf = BytesMut::new(); msg.to_wire(ctx, &mut buf)?; writer.write_all(&buf).await?; + MESSAGES_SENT.with_label_values(&[message_type_name(msg)]).inc(); Ok(()) } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index ec103bf..ac05a0b 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod advertise; pub mod config; pub mod connection; pub mod fsm; +pub mod metrics; pub mod server; pub mod session; pub mod tracing; diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs deleted file mode 100644 index 61f9df0..0000000 --- a/crates/server/src/main.rs +++ /dev/null @@ -1,29 +0,0 @@ -use bgp_server::config::Config; -use bgp_server::proto::config::BgpConfig; -use bgp_server::server::BgpServerBuilder; -use bgp_server::tracing::TracingArgs; -use clap::Parser; -use eyre::{Context, Result}; - -#[derive(Parser, Debug)] -struct ServerArgs { - /// Path to the textproto configuration file. - config: String, - - #[command(flatten)] - tracing: TracingArgs, -} - -#[tokio::main] -async fn main() -> Result<()> { - let args = ServerArgs::parse(); - args.tracing.install()?; - - let config_string = - std::fs::read_to_string(&args.config).wrap_err("Failed to open config file")?; - let proto_config = protobuf::text_format::parse_from_str::(&config_string) - .wrap_err("Failed to parse config file")?; - let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?; - - BgpServerBuilder::new(config).run().await -} diff --git a/crates/server/src/metrics.rs b/crates/server/src/metrics.rs new file mode 100644 index 0000000..11d0175 --- /dev/null +++ b/crates/server/src/metrics.rs @@ -0,0 +1,59 @@ +//! Process-wide Prometheus metrics, registered on the default registry. + +use std::sync::LazyLock; + +use bgp_packet::message::Message; +use prometheus::{ + IntCounter, IntCounterVec, IntGauge, register_int_counter, register_int_counter_vec, + register_int_gauge, +}; + +pub static MESSAGES_SENT: LazyLock = LazyLock::new(|| { + register_int_counter_vec!("bgp_messages_sent_total", "BGP messages sent, by type", &["type"]) + .expect("metric registration") +}); + +pub static MESSAGES_RECEIVED: LazyLock = LazyLock::new(|| { + register_int_counter_vec!("bgp_messages_received_total", "BGP messages received, by type", &["type"]) + .expect("metric registration") +}); + +pub static SESSIONS_ESTABLISHED_TOTAL: LazyLock = LazyLock::new(|| { + register_int_counter!("bgp_sessions_established_total", "Sessions that reached Established") + .expect("metric registration") +}); + +pub static SESSIONS_ESTABLISHED: LazyLock = LazyLock::new(|| { + register_int_gauge!("bgp_sessions_established", "Sessions currently in Established") + .expect("metric registration") +}); + +pub static LOCAL_ROUTES: LazyLock = LazyLock::new(|| { + register_int_gauge!("bgp_local_routes", "Locally originated routes").expect("metric registration") +}); + +pub static ADJ_RIB_IN_ROUTES: LazyLock = LazyLock::new(|| { + register_int_gauge!("bgp_adj_rib_in_routes", "Routes held in Adj-RIB-In across all peers") + .expect("metric registration") +}); + +/// Forces registration of every metric so they appear in scrapes before +/// their first observation. +pub fn init() { + LazyLock::force(&MESSAGES_SENT); + LazyLock::force(&MESSAGES_RECEIVED); + LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL); + LazyLock::force(&SESSIONS_ESTABLISHED); + LazyLock::force(&LOCAL_ROUTES); + LazyLock::force(&ADJ_RIB_IN_ROUTES); +} + +pub fn message_type_name(msg: &Message) -> &'static str { + match msg { + Message::Open(_) => "open", + Message::Update(_) => "update", + Message::Notification(_) => "notification", + Message::KeepAlive => "keepalive", + Message::RouteRefresh => "route_refresh", + } +} diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 132922c..5aa6194 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -67,12 +67,17 @@ impl BgpServerBuilder { } impl BgpServer { + pub fn config(&self) -> &Config { + &self.config + } + /// The addresses actually bound (useful when the config uses port 0). pub fn local_addrs(&self) -> Vec { self.listeners.iter().map(|(addr, _)| *addr).collect() } pub async fn run(self) -> Result<()> { + crate::metrics::init(); info!( router_id = %self.config.router_id, local_asn = self.config.local_asn, diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index 8a84f9a..d093429 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -14,6 +14,8 @@ use bgp_packet::message::Message; use bgp_rib::PeerId; use tokio::sync::{broadcast, mpsc}; +use crate::metrics::SESSIONS_ESTABLISHED; + const EVENT_CHANNEL_CAPACITY: usize = 256; /// Handle to one established session. Cloning is cheap. @@ -80,6 +82,7 @@ impl SessionRegistry { pub fn register(&self, handle: SessionHandle) -> SessionGuard { let peer = handle.peer; self.inner.write().expect("session registry poisoned").insert(peer, handle.clone()); + SESSIONS_ESTABLISHED.inc(); let _ = self.events.send(SessionEvent::Up(handle)); SessionGuard { registry: self.clone(), peer } } @@ -100,6 +103,7 @@ impl SessionRegistry { fn remove(&self, peer: &PeerId) { let removed = self.inner.write().expect("session registry poisoned").remove(peer); if removed.is_some() { + SESSIONS_ESTABLISHED.dec(); let _ = self.events.send(SessionEvent::Down(*peer)); } } diff --git a/crates/server/src/tracing.rs b/crates/server/src/tracing.rs index 2c22065..8d1ce92 100644 --- a/crates/server/src/tracing.rs +++ b/crates/server/src/tracing.rs @@ -4,7 +4,7 @@ use tracing::Level; use tracing_subscriber::EnvFilter; /// Default targets that we enable logging for. -const DEFAULT_TARGETS: [&str; 1] = ["bgp_server"]; +const DEFAULT_TARGETS: [&str; 3] = ["bgp", "bgp_server", "bgp_web"]; #[derive(Debug, Args)] pub struct TracingArgs { diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 0000000..75b8a77 --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bgp-web" +description = "HTTP API, event stream and metrics for the BGP daemon" +edition.workspace = true + +[dependencies] +axum.workspace = true +bgp-packet.workspace = true +bgp-primitives.workspace = true +bgp-rib.workspace = true +bgp-server.workspace = true +bytes.workspace = true +eyre.workspace = true +futures.workspace = true +prometheus.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +tracing.workspace = true + +[dev-dependencies] +reqwest.workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs new file mode 100644 index 0000000..bf1baf3 --- /dev/null +++ b/crates/web/src/api.rs @@ -0,0 +1,396 @@ +//! JSON handlers and their view types. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use bgp_packet::message::{Message, OriginPathAttribute, PathAttribute, UpdateMessage}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_primitives::parser::ParserContext; +use bgp_rib::{LocalRibError, LocalRoute, LocalRouteAttrs, SnapshotEntry}; +use bgp_server::advertise::build_announce; +use bgp_server::session::SessionHandle; +use bytes::BytesMut; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::AppState; +use crate::community; + +const MAX_OWNER_LEN: usize = 64; +const MAX_PREPEND: u8 = 16; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: impl Into) -> Self { + Self { status: StatusCode::BAD_REQUEST, message: message.into() } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} + +impl From for ApiError { + fn from(e: LocalRibError) -> Self { + let status = match e { + LocalRibError::NotAllowed(_) => StatusCode::FORBIDDEN, + LocalRibError::OwnedByOther { .. } => StatusCode::CONFLICT, + LocalRibError::OwnerLimit { .. } => StatusCode::UNPROCESSABLE_ENTITY, + LocalRibError::Closed => StatusCode::SERVICE_UNAVAILABLE, + }; + Self { status, message: e.to_string() } + } +} + +// --------------------------------------------------------------------------- +// Views +// --------------------------------------------------------------------------- + +fn unix_secs(t: SystemTime) -> u64 { + t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +#[derive(Debug, Serialize)] +pub struct StatusView { + pub router_id: String, + pub local_asn: u32, + pub version: String, + pub uptime_secs: u64, + pub sessions: usize, + pub local_routes: usize, + pub received_routes: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PeerView { + pub peer_addr: String, + pub peer_asn: u32, + pub local_addr: String, + pub established_at_unix: u64, + pub uptime_secs: u64, + pub four_octet_asn: bool, +} + +impl From<&SessionHandle> for PeerView { + fn from(s: &SessionHandle) -> Self { + Self { + peer_addr: s.peer.addr.to_string(), + peer_asn: s.peer.asn, + local_addr: s.local_addr.to_string(), + established_at_unix: unix_secs(s.established_at), + uptime_secs: s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0), + four_octet_asn: s.four_octet_asn, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalRouteView { + pub prefix: String, + pub owner: String, + pub origin: String, + pub prepend: u8, + pub med: Option, + pub communities: Vec, + pub large_communities: Vec, + pub announced_at_unix: u64, +} + +impl From<&LocalRoute> for LocalRouteView { + fn from(r: &LocalRoute) -> Self { + Self { + prefix: r.prefix.to_string(), + owner: r.owner.clone(), + origin: origin_name(r.attrs.origin).to_string(), + prepend: r.attrs.prepend, + med: r.attrs.med, + communities: r.attrs.communities.iter().copied().map(community::format_standard).collect(), + large_communities: r.attrs.large_communities.iter().map(community::format_large).collect(), + announced_at_unix: unix_secs(r.announced_at), + } + } +} + +/// Human-oriented digest of a path attribute list. +#[derive(Debug, Clone, Default, Serialize)] +pub struct AttrSummary { + pub origin: Option, + /// AS_PATH flattened in order; AS_SETs are included in place. + pub as_path: Vec, + pub next_hop: Option, + pub med: Option, + pub local_pref: Option, + pub communities: Vec, + pub large_communities: Vec, +} + +pub fn summarize(attrs: &[PathAttribute]) -> AttrSummary { + let mut s = AttrSummary::default(); + for attr in attrs { + match attr { + PathAttribute::Origin(o) => s.origin = Some(origin_name(*o).to_string()), + PathAttribute::AsPath(p) => { + s.as_path = p.segments.iter().flat_map(|seg| seg.path.iter().copied()).collect() + } + PathAttribute::NextHop(nh) => s.next_hop = Some(nh.0.to_string()), + PathAttribute::MpReachNlri(mp) => s.next_hop = Some(format!("{:?}", mp.next_hop)), + PathAttribute::MultiExitDisc(m) => s.med = Some(m.0), + PathAttribute::LocalPref(l) => s.local_pref = Some(l.0), + PathAttribute::Communities(c) => { + s.communities = c.0.iter().copied().map(community::format_standard).collect() + } + PathAttribute::LargeCommunities(l) => { + s.large_communities = l.communities.iter().map(community::format_large).collect() + } + _ => {} + } + } + s +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReceivedRouteView { + pub peer_addr: String, + pub peer_asn: u32, + pub prefix: String, + pub received_at_unix: u64, + pub summary: AttrSummary, + /// The full decoded attribute list, for the curious. + pub attributes: Vec, +} + +impl From<&SnapshotEntry> for ReceivedRouteView { + fn from(e: &SnapshotEntry) -> Self { + Self { + peer_addr: e.peer.addr.to_string(), + peer_asn: e.peer.asn, + prefix: e.prefix.to_string(), + received_at_unix: unix_secs(e.entry.received_at), + summary: summarize(&e.entry.path_attributes), + attributes: e.entry.path_attributes.clone(), + } + } +} + +/// The exact UPDATE a route produces on one session. +#[derive(Debug, Clone, Serialize)] +pub struct WireView { + pub peer_addr: String, + pub peer_asn: u32, + pub next_hop: String, + /// Complete BGP message including the 19-byte header, as lowercase hex. + pub hex: String, + pub length: usize, + pub summary: AttrSummary, + pub update: UpdateMessage, +} + +fn origin_name(o: OriginPathAttribute) -> &'static str { + match o { + OriginPathAttribute::IGP => "IGP", + OriginPathAttribute::EGP => "EGP", + OriginPathAttribute::INCOMPLETE => "INCOMPLETE", + } +} + +fn parse_origin(s: &str) -> Result { + match s.trim().to_ascii_uppercase().as_str() { + "IGP" => Ok(OriginPathAttribute::IGP), + "EGP" => Ok(OriginPathAttribute::EGP), + "INCOMPLETE" => Ok(OriginPathAttribute::INCOMPLETE), + other => Err(ApiError::bad_request(format!("origin {other:?} must be IGP, EGP or INCOMPLETE"))), + } +} + +fn parse_prefix(s: &str) -> Result { + IpPrefix::try_from(s.trim()).map_err(|e| ApiError::bad_request(format!("invalid prefix {s:?}: {e}"))) +} + +fn validate_owner(owner: &str) -> Result { + let owner = owner.trim(); + if owner.is_empty() { + return Err(ApiError::bad_request("owner must not be empty")); + } + if owner.chars().count() > MAX_OWNER_LEN { + return Err(ApiError::bad_request(format!("owner must be at most {MAX_OWNER_LEN} characters"))); + } + Ok(owner.to_string()) +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +pub struct AnnounceRequest { + pub prefix: String, + pub owner: String, + #[serde(default)] + pub origin: Option, + #[serde(default)] + pub prepend: u8, + #[serde(default)] + pub med: Option, + #[serde(default)] + pub communities: Vec, + #[serde(default)] + pub large_communities: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WithdrawRequest { + pub prefix: String, + /// Omit to withdraw regardless of owner. + #[serde(default)] + pub owner: Option, +} + +#[derive(Debug, Deserialize)] +pub struct WireQuery { + pub prefix: String, +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +pub async fn status(State(state): State) -> Json { + Json(StatusView { + router_id: state.info.router_id.to_string(), + local_asn: state.info.local_asn, + version: state.info.version.clone(), + uptime_secs: state.info.started.elapsed().as_secs(), + sessions: state.registry.list().len(), + local_routes: state.local_rib.snapshot().await.len(), + received_routes: state.adj_rib_in.snapshot().await.len(), + }) +} + +pub async fn peers(State(state): State) -> Json> { + let mut peers: Vec = state.registry.list().iter().map(PeerView::from).collect(); + peers.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr)); + Json(peers) +} + +pub async fn local_routes(State(state): State) -> Json> { + let mut routes: Vec = + state.local_rib.snapshot().await.iter().map(LocalRouteView::from).collect(); + routes.sort_by(|a, b| a.prefix.cmp(&b.prefix)); + Json(routes) +} + +pub async fn announce( + State(state): State, + Json(req): Json, +) -> Result, ApiError> { + let prefix = parse_prefix(&req.prefix)?; + let owner = validate_owner(&req.owner)?; + if req.prepend > MAX_PREPEND { + return Err(ApiError::bad_request(format!("prepend must be at most {MAX_PREPEND}"))); + } + let attrs = LocalRouteAttrs { + origin: req.origin.as_deref().map(parse_origin).transpose()?.unwrap_or(OriginPathAttribute::IGP), + prepend: req.prepend, + med: req.med, + communities: req + .communities + .iter() + .map(|c| community::parse_standard(c)) + .collect::>() + .map_err(ApiError::bad_request)?, + large_communities: req + .large_communities + .iter() + .map(|c| community::parse_large(c)) + .collect::>() + .map_err(ApiError::bad_request)?, + }; + let route = LocalRoute::new(prefix, owner, attrs); + state.local_rib.announce(route.clone()).await?; + Ok(Json(LocalRouteView::from(&route))) +} + +pub async fn withdraw( + State(state): State, + Json(req): Json, +) -> Result>, ApiError> { + let prefix = parse_prefix(&req.prefix)?; + let owner = req.owner.as_deref().map(validate_owner).transpose()?; + let removed = state.local_rib.withdraw(prefix, owner.as_deref()).await?; + Ok(Json(removed.as_ref().map(LocalRouteView::from))) +} + +/// Shows the UPDATE bytes a local route produces on each established session. +pub async fn wire( + State(state): State, + Query(q): Query, +) -> Result>, ApiError> { + let prefix = parse_prefix(&q.prefix)?; + let route = state + .local_rib + .snapshot() + .await + .into_iter() + .find(|r| r.prefix == prefix) + .ok_or_else(|| ApiError { status: StatusCode::NOT_FOUND, message: format!("{prefix} is not announced") })?; + + let mut views = vec![]; + for session in state.registry.list() { + let Some(update) = build_announce(&route, state.info.local_asn, session.local_addr.ip()) else { + continue; + }; + let ctx = ParserContext { four_octet_asn: Some(session.four_octet_asn), address_family: None }; + let mut buf = BytesMut::new(); + Message::Update(update.clone()) + .to_wire(&ctx, &mut buf) + .map_err(|e| ApiError { status: StatusCode::INTERNAL_SERVER_ERROR, message: e.to_string() })?; + views.push(WireView { + peer_addr: session.peer.addr.to_string(), + peer_asn: session.peer.asn, + next_hop: session.local_addr.ip().to_string(), + hex: buf.iter().map(|b| format!("{b:02x}")).collect(), + length: buf.len(), + summary: summarize(&update.path_attributes), + update, + }); + } + views.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr)); + Ok(Json(views)) +} + +#[derive(Debug, Clone, Serialize)] +pub struct WellKnownCommunity { + pub name: &'static str, + pub value: String, +} + +/// Names the API accepts in place of a numeric standard community. +pub async fn well_known_communities() -> Json> { + Json( + community::WELL_KNOWN + .iter() + .map(|(name, value)| WellKnownCommunity { name, value: community::format_standard(*value) }) + .collect(), + ) +} + +pub async fn received_routes(State(state): State) -> Json> { + let mut routes: Vec = + state.adj_rib_in.snapshot().await.iter().map(ReceivedRouteView::from).collect(); + routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr))); + Json(routes) +} diff --git a/crates/web/src/community.rs b/crates/web/src/community.rs new file mode 100644 index 0000000..c021772 --- /dev/null +++ b/crates/web/src/community.rs @@ -0,0 +1,80 @@ +//! Parsing and formatting of community values as they appear in the API. +//! +//! Standard communities are written `ASN:VALUE` (e.g. `65000:100`) or as one +//! of the well-known names from RFC 1997 / RFC 7999 / RFC 8326. Large +//! communities are written `GLOBAL:LOCAL1:LOCAL2` (RFC 8092). + +use bgp_packet::message::LargeCommunity; + +/// Well-known standard communities, by name. +pub const WELL_KNOWN: &[(&str, (u16, u16))] = &[ + ("GRACEFUL_SHUTDOWN", (0xFFFF, 0x0000)), + ("BLACKHOLE", (0xFFFF, 0x029A)), + ("NO_EXPORT", (0xFFFF, 0xFF01)), + ("NO_ADVERTISE", (0xFFFF, 0xFF02)), + ("NO_EXPORT_SUBCONFED", (0xFFFF, 0xFF03)), + ("NOPEER", (0xFFFF, 0xFF04)), +]; + +pub fn parse_standard(s: &str) -> Result<(u16, u16), String> { + let s = s.trim(); + if let Some((_, value)) = WELL_KNOWN.iter().find(|(name, _)| name.eq_ignore_ascii_case(s)) { + return Ok(*value); + } + let (high, low) = s + .split_once(':') + .ok_or_else(|| format!("community {s:?} must be ASN:VALUE or a well-known name"))?; + let parse = |part: &str| { + part.trim() + .parse::() + .map_err(|_| format!("community {s:?}: {part:?} is not a 16-bit number")) + }; + Ok((parse(high)?, parse(low)?)) +} + +pub fn format_standard((high, low): (u16, u16)) -> String { + format!("{high}:{low}") +} + +pub fn parse_large(s: &str) -> Result { + let s = s.trim(); + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { + return Err(format!("large community {s:?} must be GLOBAL:LOCAL1:LOCAL2")); + } + let parse = |part: &str| { + part.trim() + .parse::() + .map_err(|_| format!("large community {s:?}: {part:?} is not a 32-bit number")) + }; + Ok(LargeCommunity { global_admin: parse(parts[0])?, data_1: parse(parts[1])?, data_2: parse(parts[2])? }) +} + +pub fn format_large(c: &LargeCommunity) -> String { + format!("{}:{}:{}", c.global_admin, c.data_1, c.data_2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_roundtrip_and_names() { + assert_eq!(parse_standard("65000:100").unwrap(), (65000, 100)); + assert_eq!(parse_standard(" no_export ").unwrap(), (65535, 65281)); + assert_eq!(parse_standard("BLACKHOLE").unwrap(), (65535, 666)); + assert_eq!(format_standard((65000, 100)), "65000:100"); + assert!(parse_standard("65000").is_err()); + assert!(parse_standard("70000:1").is_err()); + assert!(parse_standard("bogus").is_err()); + } + + #[test] + fn large_roundtrip() { + let c = parse_large("65000:1:2").unwrap(); + assert_eq!(c, LargeCommunity { global_admin: 65000, data_1: 1, data_2: 2 }); + assert_eq!(format_large(&c), "65000:1:2"); + assert!(parse_large("65000:1").is_err()); + assert!(parse_large("a:b:c").is_err()); + } +} diff --git a/crates/web/src/events.rs b/crates/web/src/events.rs new file mode 100644 index 0000000..e9ec8ed --- /dev/null +++ b/crates/web/src/events.rs @@ -0,0 +1,90 @@ +//! Server-sent event stream merging session, local-route and received-route +//! changes into one feed the UI can follow. + +use std::convert::Infallible; + +use axum::extract::State; +use axum::response::sse::{Event, KeepAlive, Sse}; +use bgp_rib::{LocalRibEvent, RibEvent}; +use bgp_server::session::SessionEvent; +use futures::stream::{Stream, StreamExt}; +use serde::Serialize; +use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; + +use crate::AppState; +use crate::api::{LocalRouteView, PeerView, ReceivedRouteView, summarize}; + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WireEvent { + SessionUp { peer: PeerView }, + SessionDown { peer_addr: String, peer_asn: u32 }, + LocalAnnounced { route: LocalRouteView }, + LocalWithdrawn { prefix: String }, + ReceivedAnnounced { route: ReceivedRouteView }, + ReceivedWithdrawn { peer_addr: String, peer_asn: u32, prefix: String }, + /// The client fell behind and missed `missed` events on `stream`; it + /// should refetch the corresponding table. + Lagged { stream: &'static str, missed: u64 }, +} + +impl WireEvent { + fn from_session(ev: Result) -> Self { + match ev { + Ok(SessionEvent::Up(h)) => Self::SessionUp { peer: PeerView::from(&h) }, + Ok(SessionEvent::Down(p)) => Self::SessionDown { peer_addr: p.addr.to_string(), peer_asn: p.asn }, + Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "sessions", missed: n }, + } + } + + fn from_local(ev: Result) -> Self { + match ev { + Ok(LocalRibEvent::Announced(r)) => Self::LocalAnnounced { route: LocalRouteView::from(&r) }, + Ok(LocalRibEvent::Withdrawn { prefix }) => Self::LocalWithdrawn { prefix: prefix.to_string() }, + Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "local_routes", missed: n }, + } + } + + fn from_received(ev: Result) -> Self { + match ev { + Ok(RibEvent::Announced { peer, prefix, entry }) => Self::ReceivedAnnounced { + route: ReceivedRouteView { + peer_addr: peer.addr.to_string(), + peer_asn: peer.asn, + prefix: prefix.to_string(), + received_at_unix: entry + .received_at + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + summary: summarize(&entry.path_attributes), + attributes: entry.path_attributes, + }, + }, + Ok(RibEvent::Withdrawn { peer, prefix }) => Self::ReceivedWithdrawn { + peer_addr: peer.addr.to_string(), + peer_asn: peer.asn, + prefix: prefix.to_string(), + }, + Err(BroadcastStreamRecvError::Lagged(n)) => Self::Lagged { stream: "received_routes", missed: n }, + } + } +} + +pub async fn stream( + State(state): State, +) -> Sse>> { + let sessions = BroadcastStream::new(state.registry.subscribe()).map(WireEvent::from_session); + let local = BroadcastStream::new(state.local_rib.subscribe()).map(WireEvent::from_local); + let received = BroadcastStream::new(state.adj_rib_in.subscribe()).map(WireEvent::from_received); + + let merged = futures::stream::select(sessions, futures::stream::select(local, received)).map(|ev| { + Ok(Event::default() + .event("bgp") + .json_data(&ev) + .unwrap_or_else(|e| Event::default().event("error").data(e.to_string()))) + }); + + Sse::new(merged).keep_alive(KeepAlive::default()) +} diff --git a/crates/web/src/lib.rs b/crates/web/src/lib.rs new file mode 100644 index 0000000..f6b600b --- /dev/null +++ b/crates/web/src/lib.rs @@ -0,0 +1,76 @@ +//! HTTP surface of the daemon: a JSON API for originating routes and +//! inspecting state, a server-sent event stream, and Prometheus metrics. +//! +//! Everything here is a thin layer over handles owned by the BGP server +//! ([`SessionRegistry`], [`LocalRib`], [`AdjRibIn`]); no routing state lives +//! in this crate. + +mod api; +mod community; +mod events; +mod metrics; + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::Instant; + +use axum::Router; +use axum::routing::{delete, get, post}; +use bgp_rib::{AdjRibIn, LocalRib}; +use bgp_server::session::SessionRegistry; +use eyre::{Result, WrapErr}; +use tokio::net::TcpListener; +use tracing::info; + +pub use api::{ + AnnounceRequest, AttrSummary, LocalRouteView, PeerView, ReceivedRouteView, StatusView, + WellKnownCommunity, WireView, WithdrawRequest, +}; + +/// Static facts about the router shown in status output. +#[derive(Debug, Clone)] +pub struct RouterInfo { + pub router_id: Ipv4Addr, + pub local_asn: u32, + pub version: String, + pub started: Instant, +} + +#[derive(Clone)] +pub struct AppState { + pub info: Arc, + pub registry: SessionRegistry, + pub local_rib: LocalRib, + pub adj_rib_in: AdjRibIn, +} + +/// Builds the router. Exposed so tests can drive it without a socket. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/api/status", get(api::status)) + .route("/api/peers", get(api::peers)) + .route("/api/routes/local", get(api::local_routes).post(api::announce)) + .route("/api/routes/local", delete(api::withdraw)) + .route("/api/routes/local/withdraw", post(api::withdraw)) + .route("/api/routes/local/wire", get(api::wire)) + .route("/api/routes/received", get(api::received_routes)) + .route("/api/communities/well-known", get(api::well_known_communities)) + .route("/api/events", get(events::stream)) + .route("/metricz", get(metrics::metricz)) + .with_state(state) +} + +/// Binds `listen` and serves the API until the task is dropped. +pub async fn serve(state: AppState, listen: SocketAddr) -> Result<()> { + let listener = TcpListener::bind(listen) + .await + .wrap_err_with(|| format!("Failed to bind HTTP listener on {listen}"))?; + serve_on(state, listener).await +} + +/// Serves the API on an already-bound listener (lets tests use port 0). +pub async fn serve_on(state: AppState, listener: TcpListener) -> Result<()> { + info!(addr = %listener.local_addr()?, "HTTP API listening"); + axum::serve(listener, router(state)).await?; + Ok(()) +} diff --git a/crates/web/src/metrics.rs b/crates/web/src/metrics.rs new file mode 100644 index 0000000..cd0c6e6 --- /dev/null +++ b/crates/web/src/metrics.rs @@ -0,0 +1,21 @@ +//! Prometheus scrape endpoint. Counters are updated by the server crate as +//! messages flow; table-size gauges are refreshed at scrape time. + +use axum::extract::State; +use axum::http::{StatusCode, header}; +use axum::response::IntoResponse; +use bgp_server::metrics::{ADJ_RIB_IN_ROUTES, LOCAL_ROUTES}; +use prometheus::{Encoder, TextEncoder}; + +use crate::AppState; + +pub async fn metricz(State(state): State) -> impl IntoResponse { + LOCAL_ROUTES.set(state.local_rib.snapshot().await.len() as i64); + ADJ_RIB_IN_ROUTES.set(state.adj_rib_in.snapshot().await.len() as i64); + + let mut buf = Vec::new(); + match TextEncoder::new().encode(&prometheus::gather(), &mut buf) { + Ok(()) => (StatusCode::OK, [(header::CONTENT_TYPE, "text/plain; version=0.0.4")], buf).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} diff --git a/testdata/server.textpb b/testdata/server.textpb index 24ec4e0..dccffeb 100644 --- a/testdata/server.textpb +++ b/testdata/server.textpb @@ -13,3 +13,7 @@ local_route_policy { allowed_prefixes: "2001:db8:100::/48" max_routes_per_owner: 4 } +http_endpoint { + address: "127.0.0.1" + port: 8080 +}