diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c7f83c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +target +.git diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..145573d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +# Runs on every push and pull request. Gitea Actions reads this file too +# (it falls back to .github/workflows), so one definition serves both. +name: ci + +on: + push: + pull_request: + +env: + CARGO_TERM_COLOR: always + # Warnings fail the build here, and only here: locally they stay warnings + # so that work in progress compiles. + RUSTFLAGS: -D warnings + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - name: Format + run: cargo fmt --all -- --check + - name: Lint + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Test + run: cargo test --workspace + + # A short run of each fuzz target: enough to notice a parser change that + # breaks on input the seeds are close to. Long runs are done by hand; see + # fuzz/README.md. + fuzz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Fuzz + working-directory: fuzz + # cargo-fuzz sets its own flags; the workspace's -D warnings would + # replace them. + env: + RUSTFLAGS: "" + run: | + for target in message message_body prefix_text config; do + mkdir -p "corpus/$target" + cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60 + done diff --git a/.gitignore b/.gitignore index ea8c4bf..2c62971 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target +__pycache__/ +.DS_Store +.claude/ diff --git a/Cargo.lock b/Cargo.lock index bb01e18..c3f83b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,20 +2,11 @@ # 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" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" @@ -26,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "0.6.21" @@ -82,6 +79,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,38 +92,113 @@ 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]] -name = "bgp-integration-tests" -version = "0.0.0" +name = "bgp-bench" +version = "0.0.2" dependencies = [ + "bgp-integration-tests", "bgp-packet", "bgp-primitives", "bgp-rib", "bgp-server", "bytes", + "flate2", + "stats_alloc", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bgp-integration-tests" +version = "0.0.2" +dependencies = [ + "bgp-packet", + "bgp-primitives", + "bgp-rib", + "bgp-server", + "bgp-web", + "bytes", + "clap", "eyre", + "reqwest", + "serde_json", "tokio", ] @@ -133,6 +211,7 @@ dependencies = [ "bytes", "eyre", "nom", + "proptest", "serde", "serde_repr", "strum", @@ -145,8 +224,8 @@ version = "0.0.2" dependencies = [ "bitfield", "bytes", - "eyre", "nom", + "proptest", "serde", "serde_repr", "strum", @@ -155,33 +234,73 @@ dependencies = [ [[package]] name = "bgp-rib" -version = "0.0.0" +version = "0.0.2" dependencies = [ "bgp-packet", "bgp-primitives", + "bytes", + "foldhash", + "hashbrown 0.17.1", + "proptest", + "serde", + "thiserror 2.0.12", "tokio", "tracing", ] [[package]] name = "bgp-server" -version = "0.0.0" +version = "0.0.2" dependencies = [ "bgp-packet", "bgp-primitives", "bgp-rib", "bytes", "clap", - "eyre", "futures", "prometheus", + "proptest", "protobuf", "protobuf-codegen", + "socket2", + "thiserror 2.0.12", "tokio", "tracing", "tracing-subscriber", ] +[[package]] +name = "bgp-web" +version = "0.0.2" +dependencies = [ + "axum", + "bgp-packet", + "bgp-primitives", + "bgp-rib", + "bgp-server", + "futures", + "prometheus", + "serde", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitfield" version = "0.19.0" @@ -199,14 +318,20 @@ checksum = "07805405d3f1f3a55aab895718b488821d40458f9188059909091ae0935c344a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] name = "bitflags" -version = "2.9.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" @@ -251,7 +376,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -266,6 +391,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[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" @@ -304,12 +449,38 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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" @@ -366,7 +537,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -410,18 +581,23 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -437,6 +613,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" @@ -450,15 +819,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] +[[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" @@ -467,9 +859,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" @@ -483,6 +875,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" @@ -508,6 +906,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" @@ -515,23 +919,30 @@ 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" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", + "simd-adler32", ] [[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]] @@ -553,12 +964,12 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.36.7" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "memchr", + "autocfg", ] [[package]] @@ -596,12 +1007,36 @@ 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 = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.94" @@ -626,6 +1061,25 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "protobuf" version = "3.7.2" @@ -678,10 +1132,16 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.40" +name = "quick-error" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -692,6 +1152,44 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + [[package]] name = "redox_syscall" version = "0.5.10" @@ -731,10 +1229,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" @@ -768,6 +1291,24 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[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" @@ -776,22 +1317,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]] @@ -802,7 +1377,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]] @@ -823,6 +1410,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -837,14 +1430,26 @@ 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 = "stats_alloc" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec" + [[package]] name = "strsim" version = "0.11.1" @@ -870,7 +1475,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.100", ] [[package]] @@ -884,6 +1489,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" @@ -923,7 +1559,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -934,7 +1570,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -947,12 +1583,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", @@ -961,26 +1606,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", @@ -994,7 +1711,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.100", ] [[package]] @@ -1036,12 +1753,42 @@ 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 = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[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" @@ -1054,6 +1801,24 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[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" @@ -1069,6 +1834,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" @@ -1087,15 +1917,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" @@ -1183,3 +2004,118 @@ 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 = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + +[[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 = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[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..4c7d5fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,8 @@ [workspace] default-members = ["bin/bgp"] members = ["bin/*", "crates/*"] +# Fuzz targets need a nightly toolchain; see fuzz/README.md. +exclude = ["fuzz"] resolver = "3" [workspace.package] @@ -9,17 +11,29 @@ description = "A Border Gateway Protocol implementation" edition = "2024" homepage = "https://rayhaan.ch" license = "Apache-2.0" -name = "bgp" -repository = "https://git.rayhaan.net/rayhaan/bgp" +repository = "https://git.rayhaan.net/rayhaan/ferrix" version = "0.0.2" +# Every crate opts in with `[lints] workspace = true`. CI turns warnings into +# errors, so a lint added here is enforced. +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +dbg_macro = "warn" +todo = "warn" +unimplemented = "warn" + [workspace.dependencies] # --- Our crates --- 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" } +bgp-bench = { path = "crates/bench" } # --- General --- bitfield = "0.19.0" @@ -27,6 +41,7 @@ bytes = "1.10.1" eyre = "0.6.12" nom = "8.0.0" serde = { version = "1.0.219", features = ["derive"] } +socket2 = "0.6" serde_json = "1.0.140" serde_repr = "0.1.20" thiserror = "2.0.12" @@ -35,7 +50,13 @@ strum = { version = "0.27.1", features = ["derive"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } prometheus = "0.14.0" +proptest = "1.7" protobuf = { package = "protobuf", version = "3.7.2" } protobuf-codegen = { package = "protobuf-codegen", version = "3.7.2" } clap = { version = "4.5.53", features = ["derive"] } +foldhash = "0.2" futures = "0.3" +hashbrown = "0.17" +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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73047d3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +# Builds the daemon into a small image. Mount a configuration file at +# /etc/ferrix/server.textpb, or pass another path as the first argument. +FROM rust:1.92-bookworm AS build +WORKDIR /src +COPY . . +RUN cargo build --release -p bgp + +FROM debian:bookworm-slim +COPY --from=build /src/target/release/bgp /usr/local/bin/bgp +ENTRYPOINT ["/usr/local/bin/bgp"] +CMD ["/etc/ferrix/server.textpb", "--level", "info"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md index 46968ef..38d3d7a 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # BGP -The `bgp` crate is an implementation of the Border Gateway Protocol, which is used to exchange routing and control plane information between Autonomous Systms. +The `bgp` crate is an implementation of the Border Gateway Protocol, which is used to exchange routing and control plane information between Autonomous Systems. -The aim of this project is to provide a modern, high performance, secure and programatic and customizable software package that can be useful to network operators, researchers, and people interested in Internet. +The aim of this project is to provide a modern, high performance, secure, programmatic and customizable software package that can be useful to network operators, researchers, and people interested in Internet. ## Current status. @@ -20,8 +20,38 @@ We aim for the 1.0 release to be functional and stable to operate peering sessio - ✅ Capabilities from RFC4271 - ✅ Update message - ☐ Notification message -- ☐ Implement scaffolding required to start and configure a server -- ☐ Implement connection logic to establish sessions with peers +- ✅ Implement scaffolding required to start and configure a server +- ✅ Implement connection logic to establish sessions with peers (passive and active) +- ✅ Routes originated from the configuration, with communities, announced to all peers +- ✅ Read-only HTTP API, status page and Prometheus metrics - ☐ Implement an efficient data structure for storing prefixes (e.g. Poptrie) - ☐ Filtering for peer RIB in / out -- ☐ Main / auxillary RIBs for exchanging routes between peers +- ☐ Main / auxiliary RIBs for exchanging routes between peers + +## Development + +CI runs these three on every push, and fails on any warning: + +``` +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` + +Lints are configured once, under `[workspace.lints]` in the root `Cargo.toml`. +`unsafe` code is forbidden throughout. + +`cargo test` includes property tests: generated messages must survive being +written and read back, and no input, however damaged, may make a parser +panic. They take about a second; for a longer hunt: + +``` +PROPTEST_CASES=1000000 cargo test --release -p bgp-packet --test properties +``` + +Coverage-guided fuzz targets for the same parsers are in `fuzz/`; see +`fuzz/README.md`. CI runs each of them for a minute. + +## License + +Apache License 2.0; see `LICENSE`. diff --git a/bin/bgp/Cargo.toml b/bin/bgp/Cargo.toml index b149682..9a3a02c 100644 --- a/bin/bgp/Cargo.toml +++ b/bin/bgp/Cargo.toml @@ -1,11 +1,21 @@ [package] -description = "A Border Gateway Protocol implementation" -edition.workspace = true +name = "bgp" +description = "A Border Gateway Protocol implementation" +authors.workspace = true +edition.workspace = true homepage.workspace = true -license.workspace = true -name = "bgp" -version.workspace = true +license.workspace = true +repository.workspace = true +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 + +[lints] +workspace = true diff --git a/bin/bgp/README.md b/bin/bgp/README.md index 138be7f..7e6a707 100644 --- a/bin/bgp/README.md +++ b/bin/bgp/README.md @@ -1,9 +1,65 @@ # 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 read-only HTTP status interface. -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. The daemon is configured +entirely through that file: peers, and the routes it originates +(`originate { prefix: ... }`). There is no way to change either at runtime. -The main ongoing work is happening currently in the BGP packet crate. +When `http_endpoint` is set the binary also serves: + +| Path | Purpose | +|---|---| +| `GET /statusz` | Status page: configuration, peers and session state, originated and received routes, live event log | +| `GET /api/status` | Router ID, ASN, uptime and table sizes | +| `GET /api/config` | The configuration file as read at startup | +| `GET /api/peers` | Configured peers and established sessions | +| `GET /api/routes/local` | Originated routes | +| `GET /api/routes/received` | Adj-RIB-In contents | +| `GET /api/events` | Server-sent events for session and route changes | +| `GET /metricz` | Prometheus metrics | + +Everything under it is read-only and unauthenticated. Bind it to loopback or +put it behind a reverse proxy. + +## What it does today + +It establishes sessions (both dialling configured peers and accepting +connections, with RFC 4271 collision detection), announces its originated +routes to every peer with itself as next hop, and records what peers send in +an Adj-RIB-In. It does not select best paths, re-advertise received routes or +program a forwarding table, and it has no import or export policy. + +## Who may connect + +Connections are taken only from the addresses of configured peers, and from +each of those only a few at a time. Anything else is closed unread and +counted in `bgp_connections_refused_total`. `accept_unconfigured_peers: true` +lets any host open a session as any AS; it exists for tests on a closed +network. + +## Trying it without a router + +`fake_peer` is a minimal BGP speaker that connects to the daemon, announces a +prefix, prints every UPDATE it receives and, with `--echo`, re-announces each +received prefix back with its own ASN prepended (dropping NO_EXPORT routes). +That makes originated prefixes show up in the received table as if they had +travelled through a neighbouring AS: + +``` +cargo run -- testdata/server.textpb +cargo run -p bgp-integration-tests --example fake_peer -- \ + --server 127.0.0.1:179 --asn 65001 --announce 203.0.113.0/24 --echo +``` + +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..a00176f 100644 --- a/bin/bgp/src/main.rs +++ b/bin/bgp/src/main.rs @@ -1,6 +1,113 @@ -use eyre::Result; +use std::sync::Arc; + +use bgp_server::config::Config; +use bgp_server::proto::config::BgpConfig; +use bgp_server::server::{BgpServer, close_sessions}; +use bgp_server::tracing::TracingArgs; +use bgp_web::{AppState, RouterInfo}; +use clap::Parser; +use eyre::{Context, Result}; +use tracing::{error, info}; + +#[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()?; + log_panics(); + + 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 = BgpServer::bind(config).await?; + + if let Some(http_endpoint) = server.config().http_endpoint { + // Bound here, so that an address that cannot be bound stops the + // daemon from starting, as it does for the BGP listeners. + let listener = tokio::net::TcpListener::bind(http_endpoint) + .await + .wrap_err_with(|| format!("Failed to bind HTTP listener on {http_endpoint}"))?; + let state = AppState { + info: Arc::new( + RouterInfo::new( + server.config().router_id, + server.config().local_asn, + env!("CARGO_PKG_VERSION"), + ) + .with_config(&args.config, &config_string), + ), + registry: server.registry.clone(), + local_rib: server.local_rib.clone(), + rib: Arc::clone(&server.rib), + }; + tokio::spawn(async move { + if let Err(e) = bgp_web::serve_on(state, listener).await { + error!(err = %e, "HTTP server exited"); + } + }); + } + + // Leave politely: tell every peer we are going (Cease / Administrative + // Shutdown) rather than letting them find a dead TCP connection, which + // makes them slower to take us back. + let registry = server.registry.clone(); + tokio::select! { + result = server.run() => Ok(result?), + signal = shutdown_signal() => { + info!(signal, "Shutting down, closing BGP sessions"); + close_sessions(®istry).await; + Ok(()) + } + } +} + +/// Sends panics to the log, with where they happened. A panic is a bug, and +/// the default report goes to stderr in a format of its own, apart from +/// everything else the daemon says. The task that panicked still unwinds; +/// the server deals with what it leaves behind. +fn log_panics() { + std::panic::set_hook(Box::new(|info| { + let location = info + .location() + .map(|l| format!("{}:{}", l.file(), l.line())) + .unwrap_or_else(|| "an unknown location".into()); + let message = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "no message".into()); + // Captured only if RUST_BACKTRACE is set. + let backtrace = std::backtrace::Backtrace::capture(); + error!(%location, %message, %backtrace, "Panic: this is a bug"); + })); +} + +/// Resolves with the name of the first termination signal received. +async fn shutdown_signal() -> &'static str { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => "SIGINT", + _ = term.recv() => "SIGTERM", + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + "ctrl-c" + } } diff --git a/crates/bench/Cargo.toml b/crates/bench/Cargo.toml new file mode 100644 index 0000000..fd8bf36 --- /dev/null +++ b/crates/bench/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "bgp-bench" +description = "Benchmarks at the scale of the Internet routing table" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true +publish = false + +[dependencies] +bgp-packet.workspace = true +bgp-primitives.workspace = true +bytes.workspace = true +flate2 = "1" +stats_alloc = { version = "0.1.10", optional = true } + +[dev-dependencies] +bgp-integration-tests.workspace = true +bgp-rib.workspace = true +bgp-server.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[features] +# Counts every allocation, to report how much memory a table holds. The +# counters are shared by all threads and slow them down, so timings are +# taken without this and memory is measured in a run of its own. +count-memory = ["dep:stats_alloc"] + +[[bench]] +name = "full_table" +harness = false + +[[bench]] +name = "real_table" +harness = false + +[lints] +workspace = true diff --git a/crates/bench/benches/full_table.rs b/crates/bench/benches/full_table.rs new file mode 100644 index 0000000..4c55887 --- /dev/null +++ b/crates/bench/benches/full_table.rs @@ -0,0 +1,215 @@ +//! What it costs to take in the Internet routing table, from the bytes on +//! the wire to a chosen best route. +//! +//! Run with `cargo bench -p bgp-bench --bench full_table`. Set +//! `BENCH_PEERS` (default 4) and `BENCH_SCALE_DOWN` (default 1; 10 makes the +//! table ten times smaller) to change the size. The table is synthetic (see +//! the crate documentation); `real_table` runs on a real one. +//! +//! This is a stopwatch around whole operations at full size, not a +//! statistical micro-benchmark: the questions are "how long until a full +//! table is in" and "how much memory does it take", and their answers are +//! seconds and gigabytes. + +use std::hint::black_box; +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use bgp_bench::memory::{Region, describe}; +use bgp_bench::{Prefixes, Shape, announcements, encoded, wire_context}; +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::message::{Message, PathAttribute}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::table::{PeerIndex, PeerInfo, RibTable}; +use bytes::BytesMut; + +fn env(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn mib(bytes: usize) -> f64 { + bytes as f64 / (1024.0 * 1024.0) +} + +fn per_second(count: usize, elapsed: Duration) -> String { + let rate = count as f64 / elapsed.as_secs_f64(); + if rate >= 1e6 { + format!("{:.2} M/s", rate / 1e6) + } else { + format!("{:.0} k/s", rate / 1e3) + } +} + +fn row(what: &str, elapsed: Duration, detail: String) { + println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64()); +} + +fn main() { + let peers = env("BENCH_PEERS", 4) as u32; + let shape = Shape::INTERNET.scaled_down(env("BENCH_SCALE_DOWN", 1)); + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()); + + println!( + "== Table: {} IPv4 + {} IPv6 prefixes, from each of {peers} peers", + shape.ipv4_prefixes, shape.ipv6_prefixes + ); + println!( + "(IpPrefix is {} bytes, PathAttribute {})", + size_of::(), + size_of::() + ); + let prefixes = Prefixes::generate(shape); + let wire: Vec> = (1..=peers) + .map(|peer| encoded(&announcements(&prefixes, shape, peer))) + .collect(); + let updates: usize = wire.iter().map(Vec::len).sum(); + let routes = shape.prefixes() * peers as usize; + let wire_bytes: usize = wire.iter().flatten().map(BytesMut::len).sum(); + println!( + "{updates} UPDATE messages, {:.1} prefixes each, {:.1} MiB on the wire, {routes} routes", + routes as f64 / updates as f64, + mib(wire_bytes), + ); + + println!("\n== Reading the messages (one thread)"); + let ctx = wire_context(); + let started = Instant::now(); + for message in wire.iter().flatten() { + black_box(Message::decode(&ctx, message).expect("what we wrote is well formed")); + } + let elapsed = started.elapsed(); + row( + "decode UPDATEs", + elapsed, + format!( + "{} routes, {} messages, {:.0} MiB/s", + per_second(routes, elapsed), + per_second(updates, elapsed), + mib(wire_bytes) / elapsed.as_secs_f64() + ), + ); + + println!("\n== From bytes to best routes, without the network"); + for threads in [1, threads] { + into_table(&wire, routes, threads); + } + + println!("\n== End to end: {peers} peers send the table over TCP at once"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("a runtime"); + runtime.block_on(async { + let server = TestServer::start(65000).await.expect("a server"); + let mut senders = Vec::new(); + for (index, messages) in wire.iter().enumerate() { + let peer = index as u32 + 1; + let mut session = TestPeer::connect(server.addr).await.expect("a connection"); + session + .handshake(64512 + peer, Ipv4Addr::new(10, 0, 0, peer as u8), 90) + .await + .expect("a session"); + let stream: Vec = messages.iter().flat_map(|m| m.iter().copied()).collect(); + senders.push((session, stream)); + } + let started = Instant::now(); + let sending: Vec<_> = senders + .into_iter() + .map(|(mut session, stream)| { + tokio::spawn(async move { + session.send_raw(&stream).await.expect("the server reads"); + // Held open until the table is in. + session + }) + }) + .collect(); + while server.rib.len() < routes { + tokio::time::sleep(Duration::from_millis(2)).await; + } + let elapsed = started.elapsed(); + row( + "sessions, state machine, table", + elapsed, + format!( + "{} routes; {} prefixes with a best route", + per_second(routes, elapsed), + server.rib.prefix_count() + ), + ); + for session in sending { + drop(session.await); + } + }); +} + +/// What a session does with each message, without the session: decode it, +/// find its attribute set, put its routes in the table, choose the best. +/// `threads` threads each take whole peers. +fn into_table(wire: &[Vec], routes: usize, threads: usize) { + let region = Region::begin(); + let table = RibTable::new(threads * 16); + let peers: Vec = (0..wire.len()) + .map(|peer| { + table + .add_peer(PeerInfo { + asn: 64512 + peer as u32 + 1, + router_id: Ipv4Addr::from(peer as u32 + 1), + address: Ipv4Addr::new(192, 0, 2, peer as u8 + 1).into(), + ibgp: false, + }) + .expect("room for the peer") + }) + .collect(); + let ctx = wire_context(); + let next = AtomicUsize::new(0); + let started = Instant::now(); + std::thread::scope(|scope| { + for _ in 0..threads { + scope.spawn(|| { + loop { + let at = next.fetch_add(1, Ordering::Relaxed); + let Some(messages) = wire.get(at) else { + break; + }; + for message in messages { + let decoded = + Message::decode_with_report(&ctx, message).expect("well formed"); + let Message::Update(update) = decoded.message else { + continue; + }; + let wire = decoded.attributes_wire.as_deref().expect("kept"); + if !update.announced_routes.is_empty() { + let attrs = table.attrs().intern_received(wire, None).expect("valid"); + table.announce(peers[at], &attrs, &update.announced_routes); + } + for attribute in &update.path_attributes { + if let PathAttribute::MpReachNlri(mp) = attribute { + let attrs = table + .attrs() + .intern_received(wire, Some(&mp.next_hop)) + .expect("valid"); + table.announce(peers[at], &attrs, &mp.prefixes); + } + } + } + } + }); + } + }); + let elapsed = started.elapsed(); + assert_eq!(table.len(), routes); + row( + &format!("decode, intern, insert, choose: {threads} thread(s)"), + elapsed, + format!( + "{} routes; {}", + per_second(routes, elapsed), + describe(region.held(), routes, "route") + ), + ); +} diff --git a/crates/bench/benches/real_table.rs b/crates/bench/benches/real_table.rs new file mode 100644 index 0000000..5c98fe8 --- /dev/null +++ b/crates/bench/benches/real_table.rs @@ -0,0 +1,431 @@ +//! The same questions as `full_table`, asked of a real table dump: every +//! route that the peers of a RIPE RIS route collector had announced to it. +//! +//! Needs a dump in `crates/bench/data/` (see the README there) or at the +//! path in `BENCH_MRT`. Without one it says so and does nothing, so that +//! `cargo bench` works on a fresh checkout. `BENCH_PEERS` limits the run to +//! the peers with the most routes (default: all of them). + +use std::collections::HashSet; +use std::net::Ipv4Addr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use bgp_bench::memory::Region; +use bgp_bench::mrt::{Dump, parse_attributes}; +use bgp_packet::message::{NlriNextHop, PathAttribute}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::table::{Event, PeerIndex, PeerInfo, RibTable}; + +/// Bytes allocated and not freed since `region` began; zero when memory +/// is not being counted (see `bgp_bench::memory`). +fn retained(region: &Region) -> i64 { + region.held().unwrap_or(0) +} + +fn mib(bytes: i64) -> f64 { + bytes as f64 / (1024.0 * 1024.0) +} + +fn per_second(count: usize, elapsed: Duration) -> String { + format!("{:.2} M/s", count as f64 / elapsed.as_secs_f64() / 1e6) +} + +fn row(what: &str, elapsed: Duration, detail: String) { + println!("{what:<44} {:>9.3} s {detail}", elapsed.as_secs_f64()); +} + +fn dump_path() -> Option { + if let Ok(path) = std::env::var("BENCH_MRT") { + return Some(path.into()); + } + let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"); + let mut dumps: Vec = std::fs::read_dir(data) + .ok()? + .filter_map(|entry| Some(entry.ok()?.path())) + .filter(|path| path.extension().is_some_and(|e| e == "gz")) + .collect(); + dumps.sort(); + dumps.pop() +} + +type Attributes = (Arc<[PathAttribute]>, Option); + +fn main() { + if Region::begin().held().is_none() { + println!( + "Memory is not counted in this run: sizes print as 0. For sizes (and slower\ntimings) add --features count-memory.\n" + ); + } + let Some(path) = dump_path() else { + println!("No table dump found; see crates/bench/data/README.md. Nothing to do."); + return; + }; + println!("== Reading {}", path.display()); + let started = Instant::now(); + let dump = Dump::read(&path).expect("a readable dump"); + println!( + "{} peers, {} routes, {} distinct attribute sets, read in {:.0} s", + dump.peers.len(), + dump.route_count(), + dump.attribute_sets.len(), + started.elapsed().as_secs_f64() + ); + + let limit = std::env::var("BENCH_PEERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(dump.peers.len()); + let peers = dump.largest_peers(limit); + let peers: Vec = peers + .into_iter() + .filter(|&peer| !dump.routes[peer].is_empty()) + .collect(); + describe(&dump, &peers); + + // Parsed once, outside the timings: reading messages is measured by + // `full_table`, and here the attributes arrive already parsed. + let region = Region::begin(); + let parsed: Vec> = dump + .attribute_sets + .iter() + .map(|wire| parse_attributes(wire).map(|(a, next_hop)| (Arc::from(a), next_hop))) + .collect(); + let parsed_bytes = retained(®ion); + println!( + "\nattribute sets, parsed (Arc<[PathAttribute]>) {:.0} MiB = {:.0} bytes a set", + mib(parsed_bytes), + parsed_bytes as f64 / parsed.len() as f64 + ); + let unparsed = parsed.iter().filter(|a| a.is_none()).count(); + if unparsed > 0 { + println!("{unparsed} attribute sets could not be parsed; their routes are left out"); + } + + let threads = std::thread::available_parallelism().map_or(1, |n| n.get()); + sharded_table(&dump, &peers, &parsed, 1); + sharded_table(&dump, &peers, &parsed, threads); + followed_table(&dump, &peers, &parsed, threads); +} + +/// What the table looks like, as far as it decides what a RIB costs. +fn describe(dump: &Dump, peers: &[usize]) { + let routes: usize = peers.iter().map(|&p| dump.routes[p].len()).sum(); + let full = peers + .iter() + .filter(|&&p| dump.routes[p].len() > 800_000) + .count(); + let prefixes: HashSet = peers + .iter() + .flat_map(|&p| dump.routes[p].iter().map(|(prefix, _)| *prefix)) + .collect(); + let mut sets_used = HashSet::new(); + let mut sets_per_peer = 0usize; + for &peer in peers { + let own: HashSet = dump.routes[peer].iter().map(|(_, set)| *set).collect(); + sets_per_peer += own.len(); + sets_used.extend(own); + } + let attribute_bytes: usize = sets_used + .iter() + .map(|&set| dump.attribute_sets[set as usize].len()) + .sum(); + println!("\n== The table ({} peers used)", peers.len()); + println!("routes {routes:>12}"); + println!("peers with more than 800,000 routes {full:>12}"); + println!( + "distinct prefixes {:>12}", + prefixes.len() + ); + println!( + "routes per prefix {:>12.1}", + routes as f64 / prefixes.len() as f64 + ); + println!( + "distinct attribute sets {:>12}", + sets_used.len() + ); + println!( + " if each peer's were kept apart {sets_per_peer:>12} ({:.2}x: sets shared between peers)", + sets_per_peer as f64 / sets_used.len() as f64 + ); + println!( + "routes per attribute set {:>12.1}", + routes as f64 / sets_used.len() as f64 + ); + println!( + "attribute set on the wire, mean {:>12.0} bytes", + attribute_bytes as f64 / sets_used.len() as f64 + ); +} + +/// The sharded table, filled by `threads` threads. Each takes whole peers, +/// as sessions would, and for every UPDATE interns the attributes and then +/// announces the prefixes: everything between a decoded message and a +/// chosen best route. +fn sharded_table(dump: &Dump, peers: &[usize], parsed: &[Option], threads: usize) { + println!("\n== The sharded table, {threads} thread(s)"); + let updates = grouped(dump, peers, parsed); + let routes: usize = updates + .iter() + .flatten() + .map(|(_, prefixes)| prefixes.len()) + .sum(); + + let region = Region::begin(); + let table = Arc::new(RibTable::new(threads * 16)); + let indices = add_peers(&table, dump, peers); + let started = Instant::now(); + let best_changes = load(&table, dump, &updates, &indices, threads); + let elapsed = started.elapsed(); + assert_eq!(table.len(), routes); + let held = retained(®ion); + row( + "intern attributes, insert, choose best", + elapsed, + format!( + "{} routes; {:.0} MiB = {:.0} bytes a route, attribute sets included", + per_second(routes, elapsed), + mib(held), + held as f64 / routes as f64 + ), + ); + println!( + " {} prefixes, {} attribute sets, best route changed {} times", + table.prefix_count(), + table.attrs().len(), + best_changes + ); + + let started = Instant::now(); + let mut best = 0usize; + table.for_each_best(|prefix, _, _| { + best += usize::from(std::hint::black_box(prefix).length() > 0) + }); + row( + "read every best route", + started.elapsed(), + format!("{best} of them, in place, table not stopped"), + ); + + // Reading has to look at each route for the timing to mean anything. + let started = Instant::now(); + let mut all = 0usize; + let mut hops = 0u64; + table.for_each_route(|route| { + all += 1; + hops += u64::from(route.attrs.key().as_path_len); + }); + row( + "read every route and its attributes", + started.elapsed(), + format!( + "{all} of them, mean AS path {:.1}", + hops as f64 / all as f64 + ), + ); + + let started = Instant::now(); + let outcome = table.remove_peer(indices[0]); + let elapsed = started.elapsed(); + row( + "remove one peer (session down)", + elapsed, + format!( + "{} routes; best route changed for {} prefixes", + per_second(outcome.removed, elapsed), + outcome.best_changed + ), + ); + let started = Instant::now(); + let swept = table.attrs().sweep(); + row( + "drop attribute sets no longer used", + started.elapsed(), + format!("{swept} dropped"), + ); +} + +/// A peer's routes grouped as UPDATEs: the attribute set and the prefixes +/// announced with it. +type Updates = Vec)>>; + +fn grouped(dump: &Dump, peers: &[usize], parsed: &[Option]) -> Updates { + peers + .iter() + .map(|&peer| { + let mut routes = dump.routes[peer].clone(); + routes.sort_by_key(|(_, set)| *set); + routes + .chunk_by(|a, b| a.1 == b.1) + .filter(|group| parsed[group[0].1 as usize].is_some()) + .map(|group| { + ( + group[0].1, + group.iter().map(|(prefix, _)| *prefix).collect(), + ) + }) + .collect() + }) + .collect() +} + +fn add_peers(table: &RibTable, dump: &Dump, peers: &[usize]) -> Vec { + peers + .iter() + .map(|&peer| { + table + .add_peer(PeerInfo { + asn: dump.peers[peer].asn, + router_id: Ipv4Addr::from(peer as u32 + 1), + address: dump.peers[peer].address, + ibgp: false, + }) + .expect("room for the peer") + }) + .collect() +} + +/// Announces everything in `updates` on `threads` threads, each taking +/// whole peers. Returns how many times a best route changed. +fn load( + table: &RibTable, + dump: &Dump, + updates: &Updates, + indices: &[PeerIndex], + threads: usize, +) -> usize { + let next = AtomicUsize::new(0); + let best_changes = AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..threads { + scope.spawn(|| { + let mut changes = 0; + loop { + let at = next.fetch_add(1, Ordering::Relaxed); + let Some(updates) = updates.get(at) else { + break; + }; + for (set, prefixes) in updates { + // From the bytes, as a dump has them and as a + // session could hand them over without parsing. + let Some(attrs) = table + .attrs() + .intern_wire(&dump.attribute_sets[*set as usize]) + else { + continue; + }; + changes += table.announce(indices[at], &attrs, prefixes).best_changed; + } + } + best_changes.fetch_add(changes, Ordering::Relaxed); + }); + } + }); + best_changes.load(Ordering::Relaxed) +} + +/// The same load with someone following the best routes through the +/// journal: once reading as fast as it can while the table fills, once not +/// reading at all until the table is full. +fn followed_table(dump: &Dump, peers: &[usize], parsed: &[Option], threads: usize) { + println!("\n== The sharded table with a follower, {threads} thread(s)"); + let updates = grouped(dump, peers, parsed); + let routes: usize = updates + .iter() + .flatten() + .map(|(_, prefixes)| prefixes.len()) + .sum(); + let count = |counts: &mut (usize, usize, usize), event: Event<'_>| match event { + Event::Best { attrs, .. } => { + // Look at the route, as a follower would. + counts.0 += usize::from(attrs.key().as_path_len > 0); + } + Event::Withdrawn { .. } => counts.1 += 1, + Event::Reset | Event::Synced => counts.2 += 1, + // Not given to a follower of the best routes. + Event::Routes { .. } => {} + }; + + // Reading while the table fills. + let table = Arc::new(RibTable::new(threads * 16)); + let indices = add_peers(&table, dump, peers); + let done = AtomicBool::new(false); + let started = Instant::now(); + let (best_changes, counts, longest, after_load) = std::thread::scope(|scope| { + let follower = scope.spawn(|| { + let mut reader = table.reader(); + let mut counts = (0, 0, 0); + let mut longest = 0; + while !done.load(Ordering::Acquire) { + reader.poll(4096, |event| count(&mut counts, event)); + longest = longest.max(table.journal_len()); + } + let finishing = Instant::now(); + while !reader + .poll(4096, |event| count(&mut counts, event)) + .caught_up + {} + (counts, longest, finishing.elapsed()) + }); + let best_changes = load(&table, dump, &updates, &indices, threads); + done.store(true, Ordering::Release); + let (counts, longest, after_load) = follower.join().expect("the follower runs"); + (best_changes, counts, longest, after_load) + }); + let elapsed = started.elapsed(); + assert_eq!(table.len(), routes); + row( + "load, follower reading throughout", + elapsed, + format!( + "{} routes; caught up {:.3} s after the last route", + per_second(routes, elapsed), + after_load.as_secs_f64() + ), + ); + println!( + " best route changed {best_changes} times; the follower was given {} events for it; \n journal at most {longest} entries for {} prefixes", + counts.0 + counts.1, + table.prefix_count() + ); + drop(table); + + // Not reading: the journal has to stay small on its own account. + let table = Arc::new(RibTable::new(threads * 16)); + let indices = add_peers(&table, dump, peers); + let mut reader = table.reader(); + let mut counts = (0, 0, 0); + reader.poll(usize::MAX, |event| count(&mut counts, event)); + let started = Instant::now(); + load(&table, dump, &updates, &indices, threads); + let elapsed = started.elapsed(); + row( + "load, follower not reading", + elapsed, + format!( + "{} routes; journal holds {} entries for {} prefixes", + per_second(routes, elapsed), + table.journal_len(), + table.prefix_count() + ), + ); + let started = Instant::now(); + let mut counts = (0, 0, 0); + while !reader + .poll(4096, |event| count(&mut counts, event)) + .caught_up + {} + let elapsed = started.elapsed(); + row( + "the follower then catches up", + elapsed, + format!( + "{} events, {} of them a reset", + counts.0 + counts.1, + counts.2 + ), + ); +} diff --git a/crates/bench/data/.gitignore b/crates/bench/data/.gitignore new file mode 100644 index 0000000..13742a5 --- /dev/null +++ b/crates/bench/data/.gitignore @@ -0,0 +1,4 @@ +# Table dumps downloaded for benchmarks; see README.md. +* +!.gitignore +!README.md diff --git a/crates/bench/data/README.md b/crates/bench/data/README.md new file mode 100644 index 0000000..3d8ad5a --- /dev/null +++ b/crates/bench/data/README.md @@ -0,0 +1,18 @@ +# Table dumps for the benchmarks + +`cargo bench -p bgp-bench --bench real_table` runs on a real routing table: +a dump of every route that the peers of a route collector had announced to +it. The dumps are large and are not checked in. Fetch one into this +directory: + +```bash +curl -O https://data.ris.ripe.net/rrc03/2026.09/bview.20260915.0000.gz +``` + +That is RIPE RIS collector `rrc03`, which peers at AMS-IX: about 340 MB, +3.4 GB uncompressed. Any `bview` file from or +a RouteViews RIB works; the most recent `.gz` here is used, or the one named +by `BENCH_MRT`. + +The benchmark needs memory in proportion to the dump. `BENCH_PEERS=8` +limits it to the eight peers with the most routes. diff --git a/crates/bench/src/lib.rs b/crates/bench/src/lib.rs new file mode 100644 index 0000000..ea62f7e --- /dev/null +++ b/crates/bench/src/lib.rs @@ -0,0 +1,285 @@ +//! A synthetic copy of the Internet routing table, as the UPDATE messages a +//! peer would send to announce it. +//! +//! It is made up, not captured, so that the benchmarks need no data files +//! and give the same input every run. It is shaped like the real thing where +//! that matters for cost: +//! +//! - about a million IPv4 prefixes, most of them /24s, and about a quarter +//! of a million IPv6 prefixes, most of them /48s; +//! - prefixes that share their attributes are announced together, a handful +//! to an UPDATE, as routers do; +//! - AS paths a few hops long, and communities on most routes; +//! - several peers announce the same prefixes, each with attributes of its +//! own. + +pub mod memory; +pub mod mrt; + +use std::collections::HashSet; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use bgp_packet::message::{ + AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, + LargeCommunity, Message, MpReachNlriPathAttribute, MultiExitDiscPathAttribute, + NextHopPathAttribute, NlriNextHop, OriginPathAttribute, PathAttribute, UpdateMessage, +}; +use bgp_primitives::constants::{AfiSafi, IpVersion}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_primitives::parser::ParserContext; +use bytes::BytesMut; + +/// How big a table to make. +#[derive(Debug, Clone, Copy)] +pub struct Shape { + pub ipv4_prefixes: usize, + pub ipv6_prefixes: usize, + /// How many prefixes share one set of attributes, on average. + pub prefixes_per_attribute_set: usize, +} + +impl Shape { + /// Roughly the default-free zone in 2026. + pub const INTERNET: Self = Self { + ipv4_prefixes: 1_000_000, + ipv6_prefixes: 230_000, + prefixes_per_attribute_set: 6, + }; + + pub fn prefixes(&self) -> usize { + self.ipv4_prefixes + self.ipv6_prefixes + } + + /// The same shape, `factor` times smaller. + pub fn scaled_down(&self, factor: usize) -> Self { + Self { + ipv4_prefixes: self.ipv4_prefixes / factor, + ipv6_prefixes: self.ipv6_prefixes / factor, + ..*self + } + } +} + +/// A small deterministic generator (SplitMix64); no need for a dependency. +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Self(seed) + } + + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) + } + + /// A number below `bound`. + pub fn below(&mut self, bound: u64) -> u64 { + self.next_u64() % bound + } + + /// An entry of `weighted`, picked with probability proportional to its + /// weight. + fn pick(&mut self, weighted: &[(T, u64)]) -> T { + let total: u64 = weighted.iter().map(|(_, w)| w).sum(); + let mut at = self.below(total); + for (value, weight) in weighted { + if at < *weight { + return *value; + } + at -= weight; + } + weighted[weighted.len() - 1].0 + } +} + +/// The prefixes of the table. Every peer announces these same prefixes. +pub struct Prefixes { + pub ipv4: Vec, + pub ipv6: Vec, +} + +impl Prefixes { + pub fn generate(shape: Shape) -> Self { + let mut rng = Rng::new(0x1234_5678); + // Shares of prefix lengths, close to what the table looks like. + let v4_lengths: [(u8, u64); 9] = [ + (24, 60), + (23, 10), + (22, 11), + (21, 5), + (20, 4), + (19, 3), + (18, 2), + (16, 4), + (15, 1), + ]; + let v6_lengths: [(u8, u64); 7] = [ + (48, 48), + (32, 14), + (40, 10), + (44, 10), + (36, 6), + (29, 6), + (47, 6), + ]; + + let mut seen = HashSet::new(); + let mut ipv4 = Vec::with_capacity(shape.ipv4_prefixes); + while ipv4.len() < shape.ipv4_prefixes { + let length = rng.pick(&v4_lengths); + // Unicast space, 1.0.0.0 to 223.255.255.255. + let address = 0x0100_0000 + (rng.next_u64() as u32) % 0xdf00_0000; + let prefix = IpPrefix::new(IpVersion::Ipv4, address.to_be_bytes(), length) + .expect("four bytes cover any IPv4 prefix"); + if seen.insert(prefix) { + ipv4.push(prefix); + } + } + let mut ipv6 = Vec::with_capacity(shape.ipv6_prefixes); + while ipv6.len() < shape.ipv6_prefixes { + let length = rng.pick(&v6_lengths); + // Global unicast, 2000::/3. + let address = (0x2000_u128 << 112) | (u128::from(rng.next_u64()) << 61 >> 3); + let prefix = IpPrefix::new(IpVersion::Ipv6, address.to_be_bytes(), length) + .expect("sixteen bytes cover any IPv6 prefix"); + if seen.insert(prefix) { + ipv6.push(prefix); + } + } + Self { ipv4, ipv6 } + } +} + +/// The UPDATE messages with which the peer numbered `peer` announces the +/// whole table. The peer's AS is `64512 + peer`. +pub fn announcements(prefixes: &Prefixes, shape: Shape, peer: u32) -> Vec { + let mut rng = Rng::new(0x9999 + u64::from(peer)); + let peer_asn = 64512 + peer; + let attributes = |rng: &mut Rng| { + let hops = 2 + rng.below(4) as usize; + let mut path = vec![peer_asn]; + path.extend((0..hops).map(|_| 1 + rng.below(400_000) as u32)); + let mut attributes = vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + PathAttribute::AsPath(AsPathAttribute { + segments: vec![AsPathSegment { + ordered: true, + path, + }], + }), + ]; + let mut later = Vec::new(); + if rng.below(4) == 0 { + later.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute( + rng.below(1000) as u32, + ))); + } + let communities = rng.below(7) as usize; + if communities > 0 { + later.push(PathAttribute::Communities(CommunitiesPathAttribute( + (0..communities) + .map(|_| (rng.below(65000) as u16, rng.below(1000) as u16)) + .collect(), + ))); + } + let large = if rng.below(5) == 0 { + Some(PathAttribute::LargeCommunities( + LargeCommunitiesPathAttribute { + communities: vec![LargeCommunity { + global_admin: peer_asn, + data_1: rng.below(100) as u32, + data_2: rng.below(100) as u32, + }], + }, + )) + } else { + None + }; + (std::mem::take(&mut attributes), later, large) + }; + + let mut updates = Vec::new(); + // Prefixes that share attributes travel together. A set's prefixes are a + // run of the (randomly ordered) list, a few long. + let mut grouped = |list: &[IpPrefix], rng: &mut Rng, ipv6: bool| { + let mut rest = list; + while !rest.is_empty() { + let size = (1 + rng.below(2 * shape.prefixes_per_attribute_set as u64 - 1) as usize) + .min(rest.len()); + let (group, after) = rest.split_at(size); + rest = after; + + let (mut path_attributes, later, large) = attributes(rng); + let update = if ipv6 { + // In order of type code: MED and COMMUNITIES come before + // MP_REACH_NLRI. + path_attributes.extend(later); + path_attributes.push(PathAttribute::MpReachNlri(MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6(Ipv6Addr::new( + 0x2001, + 0xdb8, + 0, + 0, + 0, + 0, + 0, + peer as u16, + )), + prefixes: Nlri::plain(group.iter().copied()), + })); + path_attributes.extend(large); + UpdateMessage { + withdrawn_routes: vec![], + path_attributes, + announced_routes: vec![], + } + } else { + path_attributes.push(PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new( + 192, 0, 2, peer as u8, + )))); + path_attributes.extend(later); + path_attributes.extend(large); + UpdateMessage { + withdrawn_routes: vec![], + path_attributes, + announced_routes: Nlri::plain(group.iter().copied()), + } + }; + updates.push(update); + } + }; + grouped(&prefixes.ipv4, &mut rng, false); + grouped(&prefixes.ipv6, &mut rng, true); + updates +} + +/// `updates` as they are on the wire, on a session with four-octet AS +/// numbers. +pub fn encoded(updates: &[UpdateMessage]) -> Vec { + let ctx = wire_context(); + updates + .iter() + .map(|update| { + let mut wire = BytesMut::new(); + Message::Update(update.clone()) + .to_wire(&ctx, &mut wire) + .expect("a few prefixes fit one message"); + wire + }) + .collect() +} + +/// The context of the sessions the benchmarks pretend to have: an external +/// peer with four-octet AS numbers. +pub fn wire_context() -> ParserContext { + ParserContext { + four_octet_asn: true, + ibgp: false, + add_path: Vec::new(), + } +} diff --git a/crates/bench/src/memory.rs b/crates/bench/src/memory.rs new file mode 100644 index 0000000..11a961e --- /dev/null +++ b/crates/bench/src/memory.rs @@ -0,0 +1,60 @@ +//! Counting the memory a benchmark holds. +//! +//! With the `count-memory` feature every allocation is counted, and +//! [`Region::held`] says how many bytes were allocated and not freed since +//! the region began. Without it nothing is counted and the answer is +//! `None`: the counters are shared by all threads, and counting slows down +//! exactly the code whose speed is being measured. + +#[cfg(feature = "count-memory")] +mod counting { + use std::alloc::System; + + use stats_alloc::{INSTRUMENTED_SYSTEM, StatsAlloc}; + + #[global_allocator] + static GLOBAL: &StatsAlloc = &INSTRUMENTED_SYSTEM; + + pub struct Region(stats_alloc::Region<'static, System>); + + impl Region { + pub fn begin() -> Self { + Self(stats_alloc::Region::new(GLOBAL)) + } + + pub fn held(&self) -> Option { + let stats = self.0.change(); + Some(stats.bytes_allocated as i64 - stats.bytes_deallocated as i64) + } + } +} + +#[cfg(not(feature = "count-memory"))] +mod counting { + pub struct Region; + + impl Region { + pub fn begin() -> Self { + Self + } + + pub fn held(&self) -> Option { + None + } + } +} + +pub use counting::Region; + +/// "`bytes` MiB = n bytes a `unit`", or a note that memory is not being +/// counted. +pub fn describe(held: Option, count: usize, unit: &str) -> String { + match held { + Some(bytes) => format!( + "{:.0} MiB = {:.0} bytes a {unit}", + bytes as f64 / (1024.0 * 1024.0), + bytes as f64 / count as f64 + ), + None => "memory not counted (run with --features count-memory)".into(), + } +} diff --git a/crates/bench/src/mrt.rs b/crates/bench/src/mrt.rs new file mode 100644 index 0000000..586ad5b --- /dev/null +++ b/crates/bench/src/mrt.rs @@ -0,0 +1,237 @@ +//! Reads a table dump in MRT format (RFC 6396, TABLE_DUMP_V2), as published +//! by RIPE RIS and RouteViews: the routes that every peer of a route +//! collector had announced to it at one moment. +//! +//! Only what the benchmarks need is read: the IPv4 and IPv6 unicast tables. + +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, BufReader, Read}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::path::Path; + +use bgp_packet::message::{NlriNextHop, PathAttribute}; +use bgp_primitives::constants::IpVersion; +use bgp_primitives::ip_prefix::IpPrefix; +use flate2::read::MultiGzDecoder; + +use crate::wire_context; + +const TABLE_DUMP_V2: u16 = 13; +const PEER_INDEX_TABLE: u16 = 1; +const RIB_IPV4_UNICAST: u16 = 2; +const RIB_IPV6_UNICAST: u16 = 4; +const MP_REACH_NLRI: u8 = 14; + +/// A peer of the collector. +#[derive(Debug, Clone)] +pub struct DumpPeer { + pub address: IpAddr, + pub asn: u32, +} + +/// The contents of a table dump. +pub struct Dump { + /// When the dump was taken, in seconds since the Unix epoch. + pub timestamp: u32, + pub peers: Vec, + /// Every distinct list of path attributes in the dump, as it is on the + /// wire. Routes refer to these by index. + pub attribute_sets: Vec>, + /// For each peer (by index into `peers`), its routes: a prefix and the + /// index of its attributes. + pub routes: Vec>, +} + +impl Dump { + /// Reads the dump at `path`, gzip-compressed as it is published. + pub fn read(path: &Path) -> io::Result { + let mut input = BufReader::with_capacity( + 1 << 20, + MultiGzDecoder::new(BufReader::new(File::open(path)?)), + ); + let mut dump = Dump { + timestamp: 0, + peers: Vec::new(), + attribute_sets: Vec::new(), + routes: Vec::new(), + }; + let mut interned: HashMap, u32> = HashMap::new(); + let mut body = Vec::new(); + loop { + let mut header = [0u8; 12]; + match input.read_exact(&mut header) { + Ok(()) => {} + Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(e), + } + let timestamp = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); + let kind = u16::from_be_bytes([header[4], header[5]]); + let subtype = u16::from_be_bytes([header[6], header[7]]); + let length = u32::from_be_bytes([header[8], header[9], header[10], header[11]]); + body.resize(length as usize, 0); + input.read_exact(&mut body)?; + if kind != TABLE_DUMP_V2 { + continue; + } + let mut record = Cursor(&body); + let malformed = || io::Error::new(io::ErrorKind::InvalidData, "truncated MRT record"); + match subtype { + PEER_INDEX_TABLE => { + dump.timestamp = timestamp; + dump.peers = peer_index_table(&mut record).ok_or_else(malformed)?; + dump.routes = vec![Vec::new(); dump.peers.len()]; + } + RIB_IPV4_UNICAST | RIB_IPV6_UNICAST => { + let version = if subtype == RIB_IPV4_UNICAST { + IpVersion::Ipv4 + } else { + IpVersion::Ipv6 + }; + rib_entries(&mut record, version, &mut dump, &mut interned) + .ok_or_else(malformed)?; + } + _ => {} + } + } + Ok(dump) + } + + pub fn route_count(&self) -> usize { + self.routes.iter().map(Vec::len).sum() + } + + /// The peers with the most routes, most first: the ones that send a + /// full table. + pub fn largest_peers(&self, count: usize) -> Vec { + let mut peers: Vec = (0..self.peers.len()).collect(); + peers.sort_by_key(|&peer| std::cmp::Reverse(self.routes[peer].len())); + peers.truncate(count); + peers + } +} + +struct Cursor<'a>(&'a [u8]); + +impl<'a> Cursor<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + if self.0.len() < n { + return None; + } + let (taken, rest) = self.0.split_at(n); + self.0 = rest; + Some(taken) + } + fn u8(&mut self) -> Option { + self.take(1).map(|b| b[0]) + } + fn u16(&mut self) -> Option { + self.take(2).map(|b| u16::from_be_bytes([b[0], b[1]])) + } + fn u32(&mut self) -> Option { + self.take(4) + .map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]])) + } +} + +fn peer_index_table(record: &mut Cursor<'_>) -> Option> { + record.u32()?; // collector's BGP identifier + let name = record.u16()?; + record.take(name.into())?; + let count = record.u16()?; + let mut peers = Vec::with_capacity(count.into()); + for _ in 0..count { + let kind = record.u8()?; + record.u32()?; // peer's BGP identifier + let address = if kind & 1 == 1 { + let bytes: [u8; 16] = record.take(16)?.try_into().ok()?; + IpAddr::from(Ipv6Addr::from(bytes)) + } else { + let bytes: [u8; 4] = record.take(4)?.try_into().ok()?; + IpAddr::from(Ipv4Addr::from(bytes)) + }; + let asn = if kind & 2 == 2 { + record.u32()? + } else { + record.u16()?.into() + }; + peers.push(DumpPeer { address, asn }); + } + Some(peers) +} + +fn rib_entries( + record: &mut Cursor<'_>, + version: IpVersion, + dump: &mut Dump, + interned: &mut HashMap, u32>, +) -> Option<()> { + record.u32()?; // sequence number + let length = record.u8()?; + let bytes = record.take(usize::from(length.div_ceil(8)))?; + // A prefix the collector should not have had is not ours to fix. + let prefix = IpPrefix::new(version, bytes, length).ok(); + let count = record.u16()?; + for _ in 0..count { + let peer = usize::from(record.u16()?); + record.u32()?; // when the route was received + let attributes_len = record.u16()?; + let attributes = record.take(attributes_len.into())?; + let (Some(prefix), Some(routes)) = (prefix, dump.routes.get_mut(peer)) else { + continue; + }; + let id = match interned.get(attributes) { + Some(id) => *id, + None => { + let id = u32::try_from(dump.attribute_sets.len()).ok()?; + dump.attribute_sets.push(attributes.into()); + interned.insert(attributes.into(), id); + id + } + }; + routes.push((prefix, id)); + } + Some(()) +} + +/// The path attributes of a route in a dump, parsed. +/// +/// In a dump the AS numbers are always four octets wide, and +/// MP_REACH_NLRI holds only the next hop (RFC 6396 §4.3.4): the prefix is in +/// the record. The next hop is returned beside the other attributes, as the +/// RIB keeps it. `None` if the attributes cannot be parsed. +pub fn parse_attributes(mut wire: &[u8]) -> Option<(Vec, Option)> { + let ctx = wire_context(); + let mut attributes = Vec::new(); + let mut next_hop = None; + while !wire.is_empty() { + if wire.get(1) == Some(&MP_REACH_NLRI) { + let extended = wire[0] & 0x10 != 0; + let (length, header) = if extended { + ( + usize::from(u16::from_be_bytes([*wire.get(2)?, *wire.get(3)?])), + 4, + ) + } else { + (usize::from(*wire.get(2)?), 3) + }; + let value = wire.get(header..header + length)?; + let address = |bytes: &[u8]| Some(Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?)); + next_hop = match value { + [4, a, b, c, d] => Some(NlriNextHop::Ipv4(Ipv4Addr::new(*a, *b, *c, *d))), + [16, rest @ ..] => Some(NlriNextHop::Ipv6(address(rest)?)), + [32, rest @ ..] if rest.len() == 32 => Some(NlriNextHop::Ipv6WithLl { + global: address(&rest[..16])?, + link_local: address(&rest[16..])?, + }), + _ => return None, + }; + wire = &wire[header + length..]; + continue; + } + let (rest, attribute) = PathAttribute::from_wire(&ctx, wire).ok()?; + attributes.push(attribute); + wire = rest; + } + Some((attributes, next_hop)) +} diff --git a/crates/integration-tests/Cargo.toml b/crates/integration-tests/Cargo.toml index 8a08c69..7d0e14e 100644 --- a/crates/integration-tests/Cargo.toml +++ b/crates/integration-tests/Cargo.toml @@ -1,6 +1,11 @@ [package] name = "bgp-integration-tests" +authors.workspace = true edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true publish = false [dependencies] @@ -8,6 +13,13 @@ bgp-packet.workspace = true bgp-primitives.workspace = true bgp-rib.workspace = true bgp-server.workspace = true +bgp-web.workspace = true bytes.workspace = true +clap.workspace = true eyre.workspace = true +reqwest.workspace = true +serde_json.workspace = true tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/integration-tests/examples/fake_peer.rs b/crates/integration-tests/examples/fake_peer.rs new file mode 100644 index 0000000..d986fa0 --- /dev/null +++ b/crates/integration-tests/examples/fake_peer.rs @@ -0,0 +1,184 @@ +//! A minimal stand-in for a neighbouring router: connects to the daemon, +//! completes the handshake, optionally announces some prefixes, and prints +//! every UPDATE it receives. Useful for trying things out when no real +//! router is around. +//! +//! cargo run -p bgp-integration-tests --example fake_peer -- \ +//! --server 127.0.0.1:1790 --asn 65001 --announce 203.0.113.0/24 +//! +//! With `--echo`, every prefix received is re-announced back to the server +//! with this peer's ASN prepended, which lets a single daemon show its own +//! routes "coming back" in the received table. +//! +//! With `--listen ADDR` it instead waits for the server to dial it (one +//! connection at a time), which exercises the server's active side. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_packet::message::{ + AsPathAttribute, CommunitiesPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, + PathAttribute, UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use clap::Parser; +use eyre::Result; +use tokio::net::TcpListener; + +#[derive(Parser, Debug)] +struct Args { + /// Address of the daemon's BGP listener to connect to. + #[arg(long, default_value = "127.0.0.1:1790", conflicts_with = "listen")] + server: String, + /// Instead of connecting, listen here and accept connections from the + /// server, one at a time, forever. + #[arg(long)] + listen: Option, + /// ASN this peer speaks as. + #[arg(long, default_value_t = 65001)] + asn: u32, + /// Router ID to send in OPEN. + #[arg(long, default_value = "10.0.0.2")] + router_id: Ipv4Addr, + /// Hold time to negotiate. + #[arg(long, default_value_t = 30)] + hold_time: u16, + /// Prefixes to announce after the session is established. + #[arg(long)] + announce: Vec, + /// Re-announce every received prefix back to the server, with our ASN + /// prepended and NO_EXPORT-tagged routes dropped. + #[arg(long)] + echo: bool, +} + +const NO_EXPORT: (u16, u16) = (0xFFFF, 0xFF01); + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + match &args.listen { + Some(listen) => { + let listener = TcpListener::bind(listen).await?; + println!("listening on {}", listener.local_addr()?); + loop { + let (stream, from) = listener.accept().await?; + println!("accepted connection from {from}"); + if let Err(e) = run(&args, TestPeer::from_stream(stream), from).await { + println!("session ended: {e}"); + } + } + } + None => { + let addr = args.server.parse()?; + run(&args, TestPeer::connect(addr).await?, addr).await + } + } +} + +async fn run(args: &Args, mut peer: TestPeer, addr: std::net::SocketAddr) -> Result<()> { + let open = peer + .handshake(args.asn, args.router_id, args.hold_time) + .await?; + println!( + "established with {} (AS{} router-id {})", + addr, open.asn, open.identifier + ); + + for p in &args.announce { + let prefix = IpPrefix::try_from(p.as_str())?; + let update = announce(prefix, vec![args.asn], vec![(args.asn as u16, 1)]); + peer.send(&Message::Update(update)).await?; + println!("announced {prefix}"); + } + + let keepalive = Duration::from_secs((args.hold_time / 3).max(1) as u64); + let mut ticker = tokio::time::interval(keepalive); + loop { + tokio::select! { + _ = ticker.tick() => peer.send(&Message::KeepAlive).await?, + msg = peer.recv() => match msg? { + Message::KeepAlive => {} + Message::Update(u) => { + for w in &u.withdrawn_routes { + println!("WITHDRAW {w}"); + } + for a in &u.announced_routes { + println!("UPDATE {a} attrs={:?}", u.path_attributes); + } + if args.echo { + echo_back(&mut peer, &u, args.asn).await?; + } + } + Message::Notification(n) => { + println!("NOTIFICATION {}; session over", n.error); + return Ok(()); + } + other => println!("unexpected {other:?}"), + }, + } + } +} + +fn announce(prefix: IpPrefix, as_path: Vec, communities: Vec<(u16, u16)>) -> UpdateMessage { + let mut path_attributes = vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(as_path), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 2))), + ]; + if !communities.is_empty() { + path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute( + communities, + ))); + } + UpdateMessage { + withdrawn_routes: vec![], + path_attributes, + announced_routes: vec![prefix.into()], + } +} + +async fn echo_back(peer: &mut TestPeer, u: &UpdateMessage, asn: u32) -> Result<()> { + let mut as_path = vec![asn]; + let mut communities = vec![]; + for attr in &u.path_attributes { + match attr { + PathAttribute::AsPath(p) => { + as_path.extend(p.segments.iter().flat_map(|s| s.path.iter().copied())) + } + PathAttribute::Communities(c) => communities = c.0.clone(), + _ => {} + } + } + if communities.contains(&NO_EXPORT) { + println!(" not echoing: NO_EXPORT"); + return Ok(()); + } + for nlri in &u.announced_routes { + let prefix = nlri.prefix; + peer.send(&Message::Update(announce( + prefix, + as_path.clone(), + communities.clone(), + ))) + .await?; + println!(" echoed {prefix} back with path {as_path:?}"); + } + if !u.withdrawn_routes.is_empty() { + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: u.withdrawn_routes.clone(), + path_attributes: vec![], + announced_routes: vec![], + })) + .await?; + println!( + " echoed withdraw of {:?}", + u.withdrawn_routes + .iter() + .map(|p| p.to_string()) + .collect::>() + ); + } + Ok(()) +} diff --git a/crates/integration-tests/src/test_peer.rs b/crates/integration-tests/src/test_peer.rs index fc54ba7..e1f94d0 100644 --- a/crates/integration-tests/src/test_peer.rs +++ b/crates/integration-tests/src/test_peer.rs @@ -1,8 +1,8 @@ use std::net::{Ipv4Addr, SocketAddr}; -use bgp_packet::message::{BGP_HEADER_LEN, Message}; +use bgp_packet::message::{BGP_HEADER_LEN, Message, UpdateMessage}; use bgp_packet::open::{Capability, OpenMessage, OpenOption}; -use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi}; +use bgp_primitives::constants::{Afi, AfiSafi, Safi}; use bgp_primitives::parser::ParserContext; use bytes::BytesMut; use eyre::{Result, bail, eyre}; @@ -14,18 +14,41 @@ use tokio::net::TcpStream; pub struct TestPeer { stream: TcpStream, pub ctx: ParserContext, + /// The address families for which this peer sends path identifiers + /// with its prefixes (ADD-PATH, RFC 7911). What it expects to be sent + /// is `ctx.add_path`. A test sets both to match the capabilities it + /// exchanged. + pub send_add_path: Vec, + /// Whether the OPEN we sent offered four-octet AS numbers. They are in + /// use only if the server's did too. + offered_four_octet_asn: bool, } impl TestPeer { pub async fn connect(addr: SocketAddr) -> Result { let stream = TcpStream::connect(addr).await?; - Ok(Self { stream, ctx: ParserContext::default() }) + Ok(Self::from_stream(stream)) + } + + /// Wraps an already-connected stream, e.g. one accepted from a server + /// that dialled us. + pub fn from_stream(stream: TcpStream) -> Self { + Self { + stream, + ctx: ParserContext::default(), + send_add_path: Vec::new(), + offered_four_octet_asn: true, + } } /// Send a BGP message. pub async fn send(&mut self, msg: &Message) -> Result<()> { + let send_ctx = ParserContext { + add_path: self.send_add_path.clone(), + ..self.ctx.clone() + }; let mut buf = BytesMut::new(); - msg.to_wire(&self.ctx, &mut buf)?; + msg.to_wire(&send_ctx, &mut buf)?; self.stream.write_all(&buf).await?; Ok(()) } @@ -42,11 +65,23 @@ impl TestPeer { let mut buf = vec![0u8; total_len]; buf[..BGP_HEADER_LEN as usize].copy_from_slice(&header); - self.stream.read_exact(&mut buf[BGP_HEADER_LEN as usize..]).await?; + self.stream + .read_exact(&mut buf[BGP_HEADER_LEN as usize..]) + .await?; - let (_, msg) = Message::from_wire(&self.ctx, &buf) - .map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?; - Ok(msg) + Message::decode(&self.ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e}")) + } + + /// Read messages until an UPDATE arrives, skipping KEEPALIVEs. Any other + /// message type is an error. + pub async fn recv_update(&mut self) -> Result { + loop { + match self.recv().await? { + Message::Update(u) => return Ok(u), + Message::KeepAlive => {} + other => bail!("Expected UPDATE, got {:?}", other), + } + } } /// Receive and unwrap as `OpenMessage`, failing if a different type arrives. @@ -58,34 +93,67 @@ impl TestPeer { matches!(opt, OpenOption::Capabilities(caps) if caps.iter().any(|c| matches!(c, Capability::FourByteAsn { .. }))) }); - self.ctx.four_octet_asn = Some(four_byte); + self.ctx.four_octet_asn = four_byte && self.offered_four_octet_asn; Ok(o) } other => bail!("Expected OPEN, got {:?}", other), } } - /// Send an OPEN message with the given parameters and standard capabilities. + /// Send an OPEN message with the given parameters and the capabilities + /// of a typical dual-stack speaker: four-octet AS numbers, IPv4 and IPv6 + /// unicast, route refresh. pub async fn send_open( &mut self, local_asn: u32, router_id: Ipv4Addr, hold_time: u16, ) -> Result<()> { - let asn_field = if local_asn > u16::MAX as u32 { 23456 } else { local_asn as u16 }; + let capabilities = vec![ + Capability::FourByteAsn { asn: local_asn }, + Capability::MultiProtocol { + afi: Afi::IPV4, + safi: Safi::UNICAST, + }, + Capability::MultiProtocol { + afi: Afi::IPV6, + safi: Safi::UNICAST, + }, + Capability::RouteRefresh, + ]; + self.send_open_with(local_asn, router_id, hold_time, capabilities) + .await + } + + /// Send an OPEN message carrying exactly `capabilities`. + pub async fn send_open_with( + &mut self, + local_asn: u32, + router_id: Ipv4Addr, + hold_time: u16, + capabilities: Vec, + ) -> Result<()> { + self.offered_four_octet_asn = capabilities + .iter() + .any(|c| matches!(c, Capability::FourByteAsn { .. })); + // If the server's OPEN has been read already, what was settled + // then still depends on what is offered now. + self.ctx.four_octet_asn &= self.offered_four_octet_asn; + let asn_field = if local_asn > u16::MAX as u32 { + 23456 + } else { + local_asn as u16 + }; let open = Message::Open(OpenMessage { version: 4, asn: asn_field, hold_time, identifier: router_id, - options: vec![OpenOption::Capabilities(vec![ - Capability::FourByteAsn { asn: local_asn }, - Capability::MultiProtocol { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, - }, - Capability::RouteRefresh, - ])], + options: if capabilities.is_empty() { + vec![] + } else { + vec![OpenOption::Capabilities(capabilities)] + }, }); self.send(&open).await } @@ -99,6 +167,24 @@ impl TestPeer { hold_time: u16, ) -> Result { self.send_open(local_asn, router_id, hold_time).await?; + self.finish_handshake().await + } + + /// As [`TestPeer::handshake`], with an OPEN carrying exactly + /// `capabilities`. + pub async fn handshake_with( + &mut self, + local_asn: u32, + router_id: Ipv4Addr, + hold_time: u16, + capabilities: Vec, + ) -> Result { + self.send_open_with(local_asn, router_id, hold_time, capabilities) + .await?; + self.finish_handshake().await + } + + async fn finish_handshake(&mut self) -> Result { let server_open = self.recv_open().await?; // Expect server KEEPALIVE match self.recv().await? { diff --git a/crates/integration-tests/src/test_server.rs b/crates/integration-tests/src/test_server.rs index d41fa4d..04e4726 100644 --- a/crates/integration-tests/src/test_server.rs +++ b/crates/integration-tests/src/test_server.rs @@ -1,19 +1,32 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; -use bgp_rib::AdjRibIn; +use bgp_packet::message::{NlriNextHop, PathAttribute}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::LocalRib; +use bgp_rib::table::{Event, PeerIndex, Reader, RibTable}; use bgp_server::config::Config; -use bgp_server::server::BgpServerBuilder; +use bgp_server::error::ServerError; +use bgp_server::server::BgpServer; +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. /// Aborts the server task when dropped. pub struct TestServer { - handle: JoinHandle>, + handle: JoinHandle>, /// The single IPv4 loopback address the server is bound to. pub addr: SocketAddr, - /// Handle to the server's Adj-RIB-In for test introspection. - pub adj_rib_in: AdjRibIn, + /// The server's routing table. + pub rib: Arc, + /// Handle to the server's locally originated routes. + pub local_rib: LocalRib, + /// Live view of the server's established sessions. + pub registry: SessionRegistry, + pub local_asn: u32, } impl TestServer { @@ -24,28 +37,176 @@ impl TestServer { /// Start a server with an explicit hold time (useful for timer tests). pub async fn start_with_hold_time(local_asn: u32, hold_time_secs: u16) -> Result { - Self::start_inner(local_asn, hold_time_secs).await + Self::start_with(local_asn, |c| c.hold_time_secs = hold_time_secs).await } - async fn start_inner(local_asn: u32, hold_time_secs: u16) -> Result { - let config = Config { + /// Start a server after letting `customize` edit the default test config + /// (e.g. to add configured peers). + pub async fn start_with(local_asn: u32, customize: impl FnOnce(&mut Config)) -> Result { + let mut config = Config { router_id: Ipv4Addr::new(127, 0, 0, 1), local_asn, - hold_time_secs, + hold_time_secs: 90, listen_endpoints: vec![SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)], peers: vec![], + connect_retry: std::time::Duration::from_secs(5), + collision_detect_established_state: false, + // Most tests play a peer that connects from wherever the + // system puts it; the ones about who may connect turn this off. + accept_unconfigured_peers: true, + originate: vec![], + http_endpoint: None, }; + customize(&mut config); - let bound = BgpServerBuilder::new(config).bind().await?; + let bound = BgpServer::bind(config).await?; let addr = bound .local_addrs() .into_iter() .next() .expect("at least one listener"); - let adj_rib_in = bound.adj_rib_in.clone(); + let rib = Arc::clone(&bound.rib); + let local_rib = bound.local_rib.clone(); + let registry = bound.registry.clone(); let handle = tokio::spawn(bound.run()); - Ok(Self { handle, addr, adj_rib_in }) + Ok(Self { + handle, + addr, + rib, + local_rib, + registry, + local_asn, + }) + } +} + +/// A route in the server's table, as a test wants to look at it. +#[derive(Debug, Clone)] +pub struct ReceivedRoute { + pub prefix: IpPrefix, + pub peer: PeerIndex, + /// The peer's identifier for the route; zero without ADD-PATH. + pub path_id: u32, + /// The route's attributes, without MP_REACH_NLRI. + pub attributes: Vec, + /// The next hop, if the route came in MP_REACH_NLRI. + pub next_hop: Option, + /// Whether it is the best route for its prefix. + pub best: bool, +} + +/// A change of a prefix's best route. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RouteChange { + /// The prefix has a best route, or another one than before. + Announced { prefix: IpPrefix, peer: PeerIndex }, + /// The prefix has no route any more. + Withdrawn { prefix: IpPrefix }, +} + +/// The changes of best route in a server's table from the moment this was +/// made, read from the table's journal. +pub struct RouteChanges { + reader: Reader, + pending: std::collections::VecDeque, +} + +impl RouteChanges { + /// The next change, waiting for one if need be. Changes to one prefix + /// that happen before they are read arrive as one. + pub async fn recv(&mut self) -> Result { + loop { + if let Some(change) = self.pending.pop_front() { + return Ok(change); + } + self.reader.changed().await; + self.read(); + } + } + + fn read(&mut self) { + let pending = &mut self.pending; + let mut read = |event: Event<'_>| match event { + Event::Best { prefix, peer, .. } => { + pending.push_back(RouteChange::Announced { prefix, peer }) + } + Event::Withdrawn { prefix } => pending.push_back(RouteChange::Withdrawn { prefix }), + // Not given to a follower of the best routes. + Event::Routes { .. } => {} + Event::Reset | Event::Synced => {} + }; + while !self.reader.poll(1024, &mut read).caught_up {} + } +} + +impl TestServer { + /// Every route in the server's table. + pub fn received_routes(&self) -> Vec { + let mut routes = Vec::new(); + self.rib.for_each_route(|route| { + let (attributes, next_hop) = route.attrs.attributes().expect("stored attributes parse"); + routes.push(ReceivedRoute { + prefix: route.prefix, + peer: route.peer, + path_id: route.path_id, + attributes, + next_hop, + best: route.best, + }); + }); + routes + } + + /// Follows the changes of best route from now on. + pub fn route_changes(&self) -> RouteChanges { + let mut changes = RouteChanges { + reader: self.rib.reader(), + pending: Default::default(), + }; + // A new reader is first given the table as it is. That is not a + // change. + changes.read(); + changes.pending.clear(); + changes + } + + /// Waits for the server to stop by itself and returns why. A healthy + /// server never does. + pub async fn stopped(&mut self) -> Result<(), ServerError> { + (&mut self.handle) + .await + .expect("the server task does not panic") + } + + /// 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::new(Ipv4Addr::new(127, 0, 0, 1), self.local_asn, "test") + .with_config("test.textpb", format!("local_asn: {}\n", self.local_asn)), + ), + registry: self.registry.clone(), + local_rib: self.local_rib.clone(), + rib: Arc::clone(&self.rib), + }; + 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/add_path.rs b/crates/integration-tests/tests/add_path.rs new file mode 100644 index 0000000..55d175f --- /dev/null +++ b/crates/integration-tests/tests/add_path.rs @@ -0,0 +1,288 @@ +//! End-to-end tests of ADD-PATH (RFC 7911): a peer sending several routes +//! for one prefix, told apart by path identifiers. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::message::{ + AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute, + UpdateMessage, +}; +use bgp_packet::open::{AddPathEntry, AddPathSendRecv, Capability, OpenMessage}; +use bgp_primitives::constants::AfiSafi; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use bgp_server::config::PeerConfig; +use bgp_server::family::{FamilySet, SUPPORTED}; +use eyre::Result; +use serde_json::Value; + +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() +} + +/// A server on which connections from this machine are those of a +/// configured peer, with ADD-PATH offered to it as `add_path` says. +async fn server_offering( + add_path: Option, + originate: &[&str], +) -> Result { + let originate: Vec = originate + .iter() + .map(|p| LocalRoute::new(prefix(p), LocalRouteAttrs::default())) + .collect(); + TestServer::start_with(LOCAL_ASN, |c| { + c.peers = vec![PeerConfig { + name: "route-server-client".into(), + endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1), + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path, + }]; + c.originate = originate; + // Keep the dialer out of the way. + c.connect_retry = Duration::from_secs(3600); + }) + .await +} + +/// A session from a peer whose OPEN offers ADD-PATH as `offer` says, for +/// IPv4 unicast. Returns the peer and the server's OPEN. +async fn peer_offering( + server: &TestServer, + offer: Option, +) -> Result<(TestPeer, OpenMessage)> { + let mut capabilities = vec![Capability::FourByteAsn { asn: PEER_ASN }]; + capabilities.extend(SUPPORTED.into_iter().map(Capability::multiprotocol)); + if let Some(send_recv) = offer { + capabilities.push(Capability::AddPath { + entries: vec![AddPathEntry { + afi: AfiSafi::IPV4_UNICAST.afi, + safi: AfiSafi::IPV4_UNICAST.safi, + send_recv, + }], + }); + } + let mut peer = TestPeer::connect(server.addr).await?; + let open = peer + .handshake_with(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90, capabilities) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + Ok((peer, open)) +} + +fn add_path_offered(open: &OpenMessage) -> Vec<(AfiSafi, AddPathSendRecv)> { + open.capabilities() + .filter_map(|capability| match capability { + Capability::AddPath { entries } => Some(entries), + _ => None, + }) + .flatten() + .map(|entry| (entry.family(), entry.send_recv)) + .collect() +} + +fn announcement(nlri: Nlri, as_path: &[u32]) -> Message { + Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(as_path.to_vec()), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + ], + announced_routes: vec![nlri], + }) +} + +async fn wait_for_routes(server: &TestServer, routes: usize) -> Result<()> { + tokio::time::timeout(TIMEOUT, async { + while server.rib.len() != routes { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + Ok(()) +} + +/// The routes the server holds, as (path identifier, AS path, best). +fn held(server: &TestServer) -> Vec<(u32, Vec, bool)> { + let mut held: Vec<(u32, Vec, bool)> = server + .received_routes() + .into_iter() + .map(|route| { + let path = route + .attributes + .iter() + .find_map(|attribute| match attribute { + PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()), + _ => None, + }) + .unwrap_or_default(); + (route.path_id, path, route.best) + }) + .collect(); + held.sort(); + held +} + +/// A peer that sends several routes for one prefix: each is kept, the best +/// of them is chosen, and each is withdrawn on its own. +#[tokio::test] +async fn test_several_routes_for_a_prefix_from_one_peer() -> Result<()> { + let server = server_offering(Some(AddPathSendRecv::Receive), &[]).await?; + let (base, _http) = server.serve_http().await?; + let (mut peer, open) = peer_offering(&server, Some(AddPathSendRecv::Send)).await?; + + // The server offered to receive, for every family it offers. + assert_eq!( + add_path_offered(&open), + SUPPORTED.map(|family| (family, AddPathSendRecv::Receive)) + ); + // In use for the one family the peer sends for, and in that direction. + let session = &server.registry.list()[0]; + assert_eq!( + session.add_path_receive, + [AfiSafi::IPV4_UNICAST].into_iter().collect() + ); + assert!(session.add_path_send.is_empty()); + peer.send_add_path = vec![AfiSafi::IPV4_UNICAST]; + + let p = prefix("203.0.113.0/24"); + let path = |path_id| Nlri { prefix: p, path_id }; + peer.send(&announcement(path(1), &[PEER_ASN, 64999, 64998])) + .await?; + peer.send(&announcement(path(2), &[PEER_ASN, 64998])) + .await?; + wait_for_routes(&server, 2).await?; + assert_eq!( + held(&server), + [ + (1, vec![PEER_ASN, 64999, 64998], false), + (2, vec![PEER_ASN, 64998], true), + ] + ); + assert_eq!(server.rib.prefix_count(), 1); + + // The API tells them apart too. + let routes: Value = reqwest::get(format!("{base}/api/routes/received")) + .await? + .json() + .await?; + let mut ids: Vec = routes + .as_array() + .unwrap() + .iter() + .map(|route| route["path_id"].as_u64().unwrap()) + .collect(); + ids.sort_unstable(); + assert_eq!(ids, [1, 2]); + let peers: Value = reqwest::get(format!("{base}/api/peers")) + .await? + .json() + .await?; + assert_eq!( + peers[0]["add_path_receive"], + serde_json::json!(["IPv4 unicast"]) + ); + + // Announcing a path again replaces that path only. + peer.send(&announcement(path(1), &[PEER_ASN])).await?; + tokio::time::timeout(TIMEOUT, async { + while held(&server)[0].1.len() != 1 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + assert_eq!( + held(&server), + [(1, vec![PEER_ASN], true), (2, vec![PEER_ASN, 64998], false),] + ); + + // And withdrawing one leaves the other. + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: vec![path(1)], + path_attributes: vec![], + announced_routes: vec![], + })) + .await?; + wait_for_routes(&server, 1).await?; + assert_eq!(held(&server), [(2, vec![PEER_ASN, 64998], true)]); + + // When the session ends, all of its routes go. + peer.send(&announcement(path(3), &[PEER_ASN, 1, 2, 3])) + .await?; + wait_for_routes(&server, 2).await?; + drop(peer); + wait_for_routes(&server, 0).await?; + Ok(()) +} + +/// ADD-PATH changes how prefixes are written. It must be in use only when +/// both sides said so; a peer that offers it to a server that does not is +/// spoken to, and listened to, in plain BGP. +#[tokio::test] +async fn test_add_path_is_not_used_unless_both_sides_offer_it() -> Result<()> { + let server = server_offering(None, &["10.100.0.0/16"]).await?; + let (mut peer, open) = peer_offering(&server, Some(AddPathSendRecv::Both)).await?; + assert!(add_path_offered(&open).is_empty()); + let session = &server.registry.list()[0]; + assert!(session.add_path_receive.is_empty() && session.add_path_send.is_empty()); + + // Plain prefixes both ways. + let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]); + peer.send(&announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN])) + .await?; + wait_for_routes(&server, 1).await?; + assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]); + + // And the other way round: offered by the server, not by the peer. + let server = server_offering(Some(AddPathSendRecv::Both), &["10.100.0.0/16"]).await?; + let (mut peer, open) = peer_offering(&server, None).await?; + assert_eq!(add_path_offered(&open).len(), SUPPORTED.len()); + let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]); + Ok(()) +} + +/// Where the peer offered to receive and the server to send, the server's +/// prefixes carry a path identifier, and a peer that reads them so +/// understands them. +#[tokio::test] +async fn test_prefixes_are_sent_with_path_identifiers_when_agreed() -> Result<()> { + let server = server_offering(Some(AddPathSendRecv::Both), &["10.100.0.0/16"]).await?; + let (mut peer, _) = peer_offering(&server, Some(AddPathSendRecv::Receive)).await?; + let session = &server.registry.list()[0]; + assert_eq!( + session.add_path_send, + [AfiSafi::IPV4_UNICAST].into_iter().collect() + ); + assert!(session.add_path_receive.is_empty()); + + // Read as agreed: with identifiers. + peer.ctx.add_path = vec![AfiSafi::IPV4_UNICAST]; + let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes.len(), 1); + assert_eq!(update.announced_routes[0].prefix, prefix("10.100.0.0/16")); + + // The peer did not offer to send, so the server reads its prefixes + // plain. + peer.send(&announcement(prefix("203.0.113.0/24").into(), &[PEER_ASN])) + .await?; + wait_for_routes(&server, 1).await?; + assert_eq!(held(&server), [(0, vec![PEER_ASN], true)]); + Ok(()) +} diff --git a/crates/integration-tests/tests/admission.rs b/crates/integration-tests/tests/admission.rs new file mode 100644 index 0000000..7ce6fda --- /dev/null +++ b/crates/integration-tests/tests/admission.rs @@ -0,0 +1,112 @@ +//! Who may connect: only the addresses of configured peers, and each of +//! them only so many times at once. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_server::config::PeerConfig; +use bgp_server::family::FamilySet; +use eyre::Result; +use tokio::net::TcpListener; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +fn peer_at(endpoint: SocketAddr) -> PeerConfig { + PeerConfig { + name: "r1".into(), + endpoint, + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path: None, + } +} + +/// Whether the server closed the connection without sending anything. +async fn closed_unanswered(peer: &mut TestPeer) -> bool { + matches!( + tokio::time::timeout(TIMEOUT, peer.recv()).await, + Ok(Err(_)) + ) +} + +/// A host that is not a configured peer gets nothing: no OPEN to answer, +/// no session, no routes taken from it. Any host used to be able to open a +/// session as any AS it named. +#[tokio::test] +async fn test_a_host_that_is_not_a_configured_peer_is_refused() -> Result<()> { + // The only peer configured is somewhere else (documentation space). + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.accept_unconfigured_peers = false; + c.peers = vec![peer_at("192.0.2.9:179".parse().unwrap())]; + }) + .await?; + + let mut stranger = TestPeer::connect(server.addr).await?; + assert!(closed_unanswered(&mut stranger).await); + assert!(server.registry.list().is_empty()); + Ok(()) +} + +/// The address of a configured peer is let in, and held to what is +/// configured for that peer. +#[tokio::test] +async fn test_a_configured_peer_is_let_in() -> Result<()> { + // Somewhere for the server's own dialling to go; never answered. + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let endpoint = listener.local_addr()?; + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.accept_unconfigured_peers = false; + c.peers = vec![peer_at(endpoint)]; + }) + .await?; + + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert_eq!(server.registry.list()[0].name.as_deref(), Some("r1")); + Ok(()) +} + +/// One address may hold only a few connections at a time, so that one host +/// cannot use up the server's sockets with connections that never get as +/// far as a session. +#[tokio::test] +async fn test_one_address_may_open_only_a_few_connections() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + + // Four that say nothing: each is sent the server's OPEN and waited on. + let mut held = Vec::new(); + for _ in 0..4 { + let mut peer = TestPeer::connect(server.addr).await?; + tokio::time::timeout(TIMEOUT, peer.recv_open()).await??; + held.push(peer); + } + let mut one_too_many = TestPeer::connect(server.addr).await?; + assert!(closed_unanswered(&mut one_too_many).await); + + // When one of them goes, there is room again. + held.pop(); + tokio::time::timeout(TIMEOUT, async { + loop { + let mut peer = TestPeer::connect(server.addr).await?; + if peer.recv_open().await.is_ok() { + return Ok::<_, eyre::Report>(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await??; + Ok(()) +} diff --git a/crates/integration-tests/tests/announce.rs b/crates/integration-tests/tests/announce.rs new file mode 100644 index 0000000..5dcf10f --- /dev/null +++ b/crates/integration-tests/tests/announce.rs @@ -0,0 +1,500 @@ +//! End-to-end tests for locally originated routes: announcing via the +//! `LocalRib` handle must produce UPDATEs on every established session. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::{RouteChange, TestServer}; +use bgp_packet::message::{ + AsPathAttribute, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, LargeCommunity, + LocalPrefPathAttribute, NextHopPathAttribute, OriginPathAttribute, PathAttribute, + UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use bgp_server::session::SessionEvent; +use eyre::Result; + +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() +} + +fn route(p: &str, attrs: LocalRouteAttrs) -> LocalRoute { + LocalRoute::new(prefix(p), attrs) +} + +async fn established_peer(server: &TestServer) -> Result { + established_peer_with_id(server, Ipv4Addr::new(10, 0, 0, 1)).await +} + +/// Distinct routers need distinct BGP Identifiers, or the second connection +/// is (correctly) treated as a collision with the first. +async fn established_peer_with_id(server: &TestServer, router_id: Ipv4Addr) -> Result { + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, router_id, 90).await?; + Ok(peer) +} + +async fn next_update(peer: &mut TestPeer) -> Result { + tokio::time::timeout(TIMEOUT, peer.recv_update()).await? +} + +/// Waits until the registry reports `n` established sessions. +async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> { + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().len() != n { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Session registry +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_registry_tracks_session_lifecycle() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut events = server.registry.subscribe(); + assert!(server.registry.list().is_empty()); + + let peer = established_peer(&server).await?; + + let up = tokio::time::timeout(TIMEOUT, events.recv()).await??; + let SessionEvent::Up(handle) = up else { + panic!("expected Up, got {up:?}") + }; + assert_eq!(handle.peer.asn, PEER_ASN); + assert_eq!(handle.local_addr, server.addr); + assert!(handle.four_octet_asn); + assert_eq!(server.registry.list().len(), 1); + + drop(peer); + + let down = tokio::time::timeout(TIMEOUT, events.recv()).await??; + assert!( + matches!(down, SessionEvent::Down(p) if p == handle.peer), + "got {down:?}" + ); + assert!(server.registry.list().is_empty()); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Announce / withdraw +// --------------------------------------------------------------------------- + +/// A route announced while the session is up is sent immediately, with the +/// attributes the announcer chose and our address as the next hop. +#[tokio::test] +async fn test_announce_after_session_up() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established_peer(&server).await?; + wait_for_sessions(&server, 1).await?; + + server + .local_rib + .announce(route( + "10.100.1.0/24", + LocalRouteAttrs { + origin: OriginPathAttribute::IGP, + prepend: 0, + med: None, + communities: vec![(65000, 100), (65535, 65281)], + large_communities: vec![LargeCommunity { + global_admin: 65000, + data_1: 7, + data_2: 8, + }], + }, + )) + .await?; + + let update = next_update(&mut peer).await?; + assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); + assert!(update.withdrawn_routes.is_empty()); + assert_eq!( + update.path_attributes, + vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![LOCAL_ASN]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::LOCALHOST)), + PathAttribute::Communities(CommunitiesPathAttribute(vec![ + (65000, 100), + (65535, 65281) + ])), + PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute { + communities: vec![LargeCommunity { + global_admin: 65000, + data_1: 7, + data_2: 8 + }], + }), + ] + ); + Ok(()) +} + +/// Routes announced before any session exists are replayed when a session +/// reaches Established. +#[tokio::test] +async fn test_routes_replayed_on_session_up() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + server + .local_rib + .announce(route("10.100.2.0/24", LocalRouteAttrs::default())) + .await?; + + let mut peer = established_peer(&server).await?; + + let mut seen = vec![next_update(&mut peer).await?, next_update(&mut peer).await?] + .into_iter() + .flat_map(|u| u.announced_routes) + .map(|p| p.to_string()) + .collect::>(); + seen.sort(); + assert_eq!(seen, vec!["10.100.1.0/24", "10.100.2.0/24"]); + Ok(()) +} + +/// An announcement is fanned out to every established session. +#[tokio::test] +async fn test_announce_reaches_all_sessions() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?; + let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?; + wait_for_sessions(&server, 2).await?; + + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + + for peer in [&mut a, &mut b] { + let update = next_update(peer).await?; + assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); + } + Ok(()) +} + +#[tokio::test] +async fn test_withdraw_sends_withdrawn_route() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established_peer(&server).await?; + wait_for_sessions(&server, 1).await?; + + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + let _ = next_update(&mut peer).await?; + + server.local_rib.withdraw(prefix("10.100.1.0/24")).await?; + let update = next_update(&mut peer).await?; + assert_eq!(update.withdrawn_routes, vec![prefix("10.100.1.0/24")]); + assert!(update.announced_routes.is_empty()); + assert!(update.path_attributes.is_empty()); + Ok(()) +} + +/// Re-announcing with new attributes sends a fresh UPDATE (implicit replace). +#[tokio::test] +async fn test_reannounce_with_prepend_and_med() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established_peer(&server).await?; + wait_for_sessions(&server, 1).await?; + + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + let first = next_update(&mut peer).await?; + assert_eq!( + first.path_attributes[1], + AsPathAttribute::from_asns(vec![LOCAL_ASN]) + ); + + server + .local_rib + .announce(route( + "10.100.1.0/24", + LocalRouteAttrs { + prepend: 3, + med: Some(42), + ..LocalRouteAttrs::default() + }, + )) + .await?; + let second = next_update(&mut peer).await?; + assert_eq!(second.announced_routes, vec![prefix("10.100.1.0/24")]); + assert_eq!( + second.path_attributes[1], + AsPathAttribute::from_asns(vec![LOCAL_ASN; 4]) + ); + assert!( + second + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::MultiExitDisc(m) if m.0 == 42)) + ); + Ok(()) +} + +/// The session stays healthy (keepalives keep flowing) while UPDATEs are sent. +#[tokio::test] +async fn test_keepalives_continue_alongside_updates() -> Result<()> { + let server = TestServer::start_with_hold_time(LOCAL_ASN, 3).await?; + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 3) + .await?; + wait_for_sessions(&server, 1).await?; + + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + + let mut saw_update = false; + let mut saw_keepalive = false; + tokio::time::timeout(TIMEOUT, async { + while !(saw_update && saw_keepalive) { + match peer.recv().await? { + bgp_packet::message::Message::Update(_) => saw_update = true, + bgp_packet::message::Message::KeepAlive => saw_keepalive = true, + other => eyre::bail!("unexpected {other:?}"), + } + } + Ok::<_, eyre::Error>(()) + }) + .await??; + Ok(()) +} + +/// Routes learned from a peer disappear from the Adj-RIB-In when its +/// session ends, with a Withdrawn event for each. +#[tokio::test] +async fn test_received_routes_removed_when_session_closes() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut rib_events = server.route_changes(); + let mut session_events = server.registry.subscribe(); + let mut peer = established_peer(&server).await?; + wait_for_sessions(&server, 1).await?; + + peer.send(&bgp_packet::message::Message::Update( + bgp_packet::message::UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![PEER_ASN]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 1))), + ], + announced_routes: vec![ + prefix("203.0.113.0/24").into(), + prefix("198.51.100.0/24").into(), + ], + }, + )) + .await?; + for _ in 0..2 { + let ev = tokio::time::timeout(TIMEOUT, rib_events.recv()).await??; + assert!(matches!(ev, RouteChange::Announced { .. }), "{ev:?}"); + } + assert_eq!(server.received_routes().len(), 2); + + drop(peer); + + let mut withdrawn = vec![]; + for _ in 0..2 { + match tokio::time::timeout(TIMEOUT, rib_events.recv()).await?? { + RouteChange::Withdrawn { prefix, .. } => withdrawn.push(prefix.to_string()), + other => panic!("expected Withdrawn, got {other:?}"), + } + } + withdrawn.sort(); + assert_eq!(withdrawn, vec!["198.51.100.0/24", "203.0.113.0/24"]); + assert!(server.received_routes().is_empty()); + + // Withdrawals precede the Down event. + let _up = tokio::time::timeout(TIMEOUT, session_events.recv()).await??; + let down = tokio::time::timeout(TIMEOUT, session_events.recv()).await??; + assert!(matches!(down, SessionEvent::Down(_)), "{down:?}"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Route refresh and shutdown +// --------------------------------------------------------------------------- + +/// ROUTE-REFRESH for IPv4 unicast exactly as a router sends it (RFC 2918): +/// 19-byte header plus AFI, reserved, SAFI. +#[rustfmt::skip] +const ROUTE_REFRESH_IPV4: &[u8] = &[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x17, 0x05, + 0x00, 0x01, 0x00, 0x01, +]; + +/// A peer whose import policy changed asks for our routes again; it must get +/// every local route re-sent, and the session must stay up. +#[tokio::test] +async fn test_route_refresh_resends_local_routes() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established_peer(&server).await?; + wait_for_sessions(&server, 1).await?; + + server + .local_rib + .announce(route("10.100.1.0/24", LocalRouteAttrs::default())) + .await?; + server + .local_rib + .announce(route("10.100.2.0/24", LocalRouteAttrs::default())) + .await?; + let _ = next_update(&mut peer).await?; + let _ = next_update(&mut peer).await?; + + peer.send_raw(ROUTE_REFRESH_IPV4).await?; + + let mut resent = vec![next_update(&mut peer).await?, next_update(&mut peer).await?] + .into_iter() + .flat_map(|u| u.announced_routes) + .map(|p| p.to_string()) + .collect::>(); + resent.sort(); + assert_eq!(resent, vec!["10.100.1.0/24", "10.100.2.0/24"]); + assert_eq!( + server.registry.list().len(), + 1, + "session must survive a route refresh" + ); + Ok(()) +} + +/// Shutting down tells each peer why (Cease / Administrative Shutdown) and +/// closes the sessions, instead of leaving dead TCP connections behind. +#[tokio::test] +async fn test_shutdown_sends_cease_to_every_peer() -> Result<()> { + use bgp_packet::errors::{BgpError, CeaseSubcode}; + use bgp_packet::message::Message; + + let server = TestServer::start(LOCAL_ASN).await?; + let mut a = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 1)).await?; + let mut b = established_peer_with_id(&server, Ipv4Addr::new(10, 0, 0, 2)).await?; + wait_for_sessions(&server, 2).await?; + + let left = server.registry.shutdown(Duration::from_secs(2)).await; + assert_eq!(left, 0, "sessions still open after shutdown"); + assert!(server.registry.is_shutting_down()); + + for peer in [&mut a, &mut b] { + let notif = tokio::time::timeout(TIMEOUT, async { + loop { + match peer.recv().await? { + Message::Notification(n) => return Ok::<_, eyre::Error>(n), + Message::KeepAlive => {} + other => eyre::bail!("unexpected {other:?}"), + } + } + }) + .await??; + assert!( + matches!(notif.error, BgpError::Cease(CeaseSubcode::AdminShutdown)), + "{notif:?}" + ); + assert!(peer.is_closed().await); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Routes from the configuration +// --------------------------------------------------------------------------- + +/// Routes listed under `originate` are in the table before any peer +/// connects, and each session is sent them when it comes up. +#[tokio::test] +async fn test_configured_routes_are_announced_to_every_new_session() -> Result<()> { + let attrs = LocalRouteAttrs { + med: Some(7), + communities: vec![(65000, 100)], + ..Default::default() + }; + let configured = vec![ + route("10.100.1.0/24", attrs), + route("10.100.2.0/24", LocalRouteAttrs::default()), + ]; + let server = TestServer::start_with(LOCAL_ASN, |c| c.originate = configured).await?; + assert_eq!(server.local_rib.snapshot().await.unwrap().len(), 2); + + for router_id in [Ipv4Addr::new(10, 0, 0, 1), Ipv4Addr::new(10, 0, 0, 2)] { + let mut peer = established_peer_with_id(&server, router_id).await?; + let mut seen = [next_update(&mut peer).await?, next_update(&mut peer).await?]; + seen.sort_by_key(|u| u.announced_routes[0].to_string()); + assert_eq!(seen[0].announced_routes, vec![prefix("10.100.1.0/24")]); + assert!( + seen[0] + .path_attributes + .contains(&PathAttribute::Communities(CommunitiesPathAttribute(vec![ + (65000, 100) + ]))) + ); + assert_eq!(seen[1].announced_routes, vec![prefix("10.100.2.0/24")]); + } + Ok(()) +} + +/// A peer in our own AS is sent a route we originate with an empty AS path +/// and a LOCAL_PREF, whatever prepending is configured: our AS number goes +/// on the path only as the route leaves the AS. It used to be sent as to +/// an external peer, which an internal one refuses. +#[tokio::test] +async fn test_a_peer_in_our_own_as_gets_an_empty_path_and_a_local_pref() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(LOCAL_ASN, Ipv4Addr::new(10, 0, 0, 1), 90) + .await?; + // The peer reads as a router in the server's AS does: one outside it + // would drop the LOCAL_PREF. + peer.ctx.ibgp = true; + wait_for_sessions(&server, 1).await?; + assert!(server.registry.list()[0].ibgp); + + server + .local_rib + .announce(route( + "10.100.1.0/24", + LocalRouteAttrs { + prepend: 2, + ..LocalRouteAttrs::default() + }, + )) + .await?; + let update = next_update(&mut peer).await?; + assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); + assert!( + update + .path_attributes + .contains(&PathAttribute::AsPath(AsPathAttribute { segments: vec![] })), + "{:?}", + update.path_attributes + ); + assert!( + update + .path_attributes + .contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(100))), + "{:?}", + update.path_attributes + ); + Ok(()) +} diff --git a/crates/integration-tests/tests/basic_connection.rs b/crates/integration-tests/tests/basic_connection.rs index c69ed82..f11f3ca 100644 --- a/crates/integration-tests/tests/basic_connection.rs +++ b/crates/integration-tests/tests/basic_connection.rs @@ -2,17 +2,41 @@ use std::net::Ipv4Addr; use std::time::Duration; use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::RouteChange; use bgp_integration_tests::test_server::TestServer; -use bgp_packet::errors::{BgpError, CeaseSubcode}; +use bgp_packet::errors::{BgpError, CeaseSubcode, FsmSubcode, MsgHeaderSubcode, UpdateMsgSubcode}; use bgp_packet::message::{ - Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, NlriNextHop, - NotificationMessage, PathAttribute, UpdateMessage, + AsPathAttribute, Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, + NextHopPathAttribute, NlriNextHop, NotificationMessage, OriginPathAttribute, PathAttribute, + UpdateMessage, }; -use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi}; +use bgp_primitives::constants::{AfiSafi, IpVersion}; use bgp_primitives::ip_prefix::IpPrefix; -use bgp_rib::RibEvent; use eyre::Result; +const TIMEOUT: Duration = Duration::from_secs(5); + +/// The attributes every announcement of IPv4 routes must carry. An UPDATE +/// without them has its routes withdrawn instead (RFC 7606). +fn mandatory_attributes() -> Vec { + let mut attributes = with_origin_and_as_path(PathAttribute::NextHop(NextHopPathAttribute( + Ipv4Addr::new(192, 0, 2, 1), + ))); + // In order of type code: ORIGIN, AS_PATH, NEXT_HOP. + attributes.rotate_left(1); + attributes +} + +/// `first` followed by the attributes every announcement must carry. +/// Routes in MP_REACH_NLRI have their next hop there. +fn with_origin_and_as_path(first: PathAttribute) -> Vec { + vec![ + first, + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![65001]), + ] +} + // --------------------------------------------------------------------------- // Connectivity // --------------------------------------------------------------------------- @@ -74,6 +98,147 @@ async fn test_bad_marker_does_not_crash_server() -> Result<()> { Ok(()) } +/// Reads until a NOTIFICATION arrives, skipping KEEPALIVEs. +async fn expect_notification(peer: &mut TestPeer) -> Result { + tokio::time::timeout(TIMEOUT, async { + loop { + match peer.recv().await? { + Message::Notification(n) => return Ok(n), + Message::KeepAlive => {} + other => eyre::bail!("expected NOTIFICATION, got {other:?}"), + } + } + }) + .await? +} + +/// A header with the given length and type, followed by `body`. +fn framed(length: u16, msg_type: u8, body: &[u8]) -> Vec { + let mut buf = vec![0xFF; 16]; + buf.extend_from_slice(&length.to_be_bytes()); + buf.push(msg_type); + buf.extend_from_slice(body); + buf +} + +/// What the peer sent decides the NOTIFICATION it gets back (RFC 4271 §6), +/// in every state, and the session ends. +#[tokio::test] +async fn test_malformed_messages_are_answered_with_the_matching_notification() -> Result<()> { + let server = TestServer::start(65000).await?; + let header = |subcode| BgpError::MsgHeader(subcode); + let mut bad_marker = framed(19, 4, &[]); + bad_marker[0] = 0; + + let cases: Vec<(&str, Vec, BgpError, Vec)> = vec![ + ( + "length beyond the maximum", + framed(5000, 2, &[]), + header(MsgHeaderSubcode::BadMessageLength), + 5000u16.to_be_bytes().to_vec(), + ), + ( + "length below a header", + framed(5, 4, &[]), + header(MsgHeaderSubcode::BadMessageLength), + vec![0, 5], + ), + ( + "unknown message type", + framed(19, 9, &[]), + header(MsgHeaderSubcode::BadMessageType), + vec![9], + ), + ( + "marker not all ones", + bad_marker, + header(MsgHeaderSubcode::ConnNotSynchronized), + vec![], + ), + ( + "UPDATE with attributes running past its end", + framed(23, 2, &[0, 0, 0, 40]), + BgpError::UpdateMsg(UpdateMsgSubcode::MalformedAttrs), + vec![], + ), + ]; + + for (i, (what, bytes, error, data)) in cases.into_iter().enumerate() { + // Established: a distinct router ID each time, so that a session + // still closing does not collide with the next one. + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 1, i as u8 + 1), 90) + .await?; + peer.send_raw(&bytes).await?; + let notification = expect_notification(&mut peer).await?; + assert_eq!( + (notification.error, notification.data), + (error.clone(), data.clone()), + "{what}, Established" + ); + + // OpenSent: the same bytes instead of an OPEN. + let mut peer = TestPeer::connect(server.addr).await?; + let _ = peer.recv_open().await?; + peer.send_raw(&bytes).await?; + let notification = expect_notification(&mut peer).await?; + assert_eq!( + (notification.error, notification.data), + (error, data), + "{what}, OpenSent" + ); + } + + // A well-formed message at the wrong time is an FSM error, not a + // message error. + let mut peer = TestPeer::connect(server.addr).await?; + let _ = peer.recv_open().await?; + peer.send(&Message::KeepAlive).await?; + let notification = expect_notification(&mut peer).await?; + assert_eq!( + notification.error, + BgpError::FsmError(FsmSubcode::UnexpectedOpenSent) + ); + Ok(()) +} + +/// A message may arrive in pieces, with the server's own timers firing in +/// between. The server once threw away the piece it had already read when +/// that happened, so the message was silently lost (and the stream was only +/// back in step if the remainder happened to be thrown away as well). +#[tokio::test] +async fn test_message_split_across_a_keepalive_tick_is_still_one_message() -> Result<()> { + // Hold time 3 s: the server sends a KEEPALIVE every second. + let server = TestServer::start_with_hold_time(65000, 3).await?; + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 3).await?; + + let prefix = IpPrefix::new(IpVersion::Ipv4, vec![10], 8)?; + let mut update = bytes::BytesMut::new(); + Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: mandatory_attributes(), + announced_routes: vec![prefix.into()], + }) + .to_wire(&peer.ctx, &mut update)?; + + // The first half, a pause long enough for the server's keepalive timer + // to fire, then the rest. + peer.send_raw(&update[..10]).await?; + tokio::time::sleep(Duration::from_millis(1300)).await; + peer.send_raw(&update[10..]).await?; + + tokio::time::timeout(TIMEOUT, async { + while !server.received_routes().iter().any(|e| e.prefix == prefix) { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .map_err(|_| eyre::eyre!("the split UPDATE never reached the RIB"))?; + assert_eq!(server.registry.list().len(), 1, "session was dropped"); + Ok(()) +} + // --------------------------------------------------------------------------- // OpenConfirm → Established // --------------------------------------------------------------------------- @@ -173,9 +338,9 @@ async fn test_server_accepts_update() -> Result<()> { let update = Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![], + path_attributes: mandatory_attributes(), // 10.0.0.0/8 — one byte of prefix for a /8. - announced_routes: vec![IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?], + announced_routes: vec![IpPrefix::new(IpVersion::Ipv4, vec![10], 8)?.into()], }); peer.send(&update).await?; @@ -193,15 +358,16 @@ async fn test_server_accepts_ipv6_update() -> Result<()> { .await?; // 2001:db8::/32 - let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; + let prefix = IpPrefix::new(IpVersion::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; let update = Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, - next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), - prefixes: vec![prefix], - })], + path_attributes: with_origin_and_as_path(PathAttribute::MpReachNlri( + MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), + prefixes: vec![prefix.into()], + }, + )), announced_routes: vec![], }); peer.send(&update).await?; @@ -219,26 +385,27 @@ async fn test_server_accepts_ipv6_update() -> Result<()> { #[tokio::test] async fn test_rib_records_ipv4_announce() -> Result<()> { let server = TestServer::start(65000).await?; - let mut events = server.adj_rib_in.subscribe(); + let mut events = server.route_changes(); let mut peer = TestPeer::connect(server.addr).await?; - peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90) + .await?; - let prefix = IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?; + let prefix = IpPrefix::new(IpVersion::Ipv4, vec![10], 8)?; peer.send(&Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![], - announced_routes: vec![prefix.clone()], + path_attributes: mandatory_attributes(), + announced_routes: vec![prefix.into()], })) .await?; let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??; assert!( - matches!(&event, RibEvent::Announced { prefix: p, .. } if p == &prefix), + matches!(&event, RouteChange::Announced { prefix: p, .. } if p == &prefix), "Expected Announced for 10.0.0.0/8, got {event:?}", ); - let snapshot = server.adj_rib_in.snapshot().await; + let snapshot = server.received_routes(); assert!( snapshot.iter().any(|e| e.prefix == prefix), "10.0.0.0/8 missing from RIB snapshot", @@ -250,25 +417,26 @@ async fn test_rib_records_ipv4_announce() -> Result<()> { #[tokio::test] async fn test_rib_records_ipv4_withdraw() -> Result<()> { let server = TestServer::start(65000).await?; - let mut events = server.adj_rib_in.subscribe(); + let mut events = server.route_changes(); let mut peer = TestPeer::connect(server.addr).await?; - peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90) + .await?; - let prefix = IpPrefix::new(AddressFamilyId::Ipv4, vec![10], 8)?; + let prefix = IpPrefix::new(IpVersion::Ipv4, vec![10], 8)?; // Announce first. peer.send(&Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![], - announced_routes: vec![prefix.clone()], + path_attributes: mandatory_attributes(), + announced_routes: vec![prefix.into()], })) .await?; tokio::time::timeout(Duration::from_secs(1), events.recv()).await??; // Now withdraw. peer.send(&Message::Update(UpdateMessage { - withdrawn_routes: vec![prefix.clone()], + withdrawn_routes: vec![prefix.into()], path_attributes: vec![], announced_routes: vec![], })) @@ -276,11 +444,11 @@ async fn test_rib_records_ipv4_withdraw() -> Result<()> { let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??; assert!( - matches!(&event, RibEvent::Withdrawn { prefix: p, .. } if p == &prefix), + matches!(&event, RouteChange::Withdrawn { prefix: p, .. } if p == &prefix), "Expected Withdrawn for 10.0.0.0/8, got {event:?}", ); - let snapshot = server.adj_rib_in.snapshot().await; + let snapshot = server.received_routes(); assert!( !snapshot.iter().any(|e| e.prefix == prefix), "10.0.0.0/8 should have been removed from RIB snapshot", @@ -292,31 +460,33 @@ async fn test_rib_records_ipv4_withdraw() -> Result<()> { #[tokio::test] async fn test_rib_records_ipv6_announce() -> Result<()> { let server = TestServer::start(65000).await?; - let mut events = server.adj_rib_in.subscribe(); + let mut events = server.route_changes(); let mut peer = TestPeer::connect(server.addr).await?; - peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90) + .await?; - let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; + let prefix = IpPrefix::new(IpVersion::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; peer.send(&Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, - next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), - prefixes: vec![prefix.clone()], - })], + path_attributes: with_origin_and_as_path(PathAttribute::MpReachNlri( + MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), + prefixes: vec![prefix.into()], + }, + )), announced_routes: vec![], })) .await?; let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??; assert!( - matches!(&event, RibEvent::Announced { prefix: p, .. } if p == &prefix), + matches!(&event, RouteChange::Announced { prefix: p, .. } if p == &prefix), "Expected Announced for 2001:db8::/32, got {event:?}", ); - let snapshot = server.adj_rib_in.snapshot().await; + let snapshot = server.received_routes(); assert!( snapshot.iter().any(|e| e.prefix == prefix), "2001:db8::/32 missing from RIB snapshot", @@ -328,22 +498,24 @@ async fn test_rib_records_ipv6_announce() -> Result<()> { #[tokio::test] async fn test_rib_records_ipv6_withdraw() -> Result<()> { let server = TestServer::start(65000).await?; - let mut events = server.adj_rib_in.subscribe(); + let mut events = server.route_changes(); let mut peer = TestPeer::connect(server.addr).await?; - peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + peer.handshake(65001, Ipv4Addr::new(10, 0, 0, 1), 90) + .await?; - let prefix = IpPrefix::new(AddressFamilyId::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; + let prefix = IpPrefix::new(IpVersion::Ipv6, vec![0x20, 0x01, 0x0d, 0xb8], 32)?; // Announce first. peer.send(&Message::Update(UpdateMessage { withdrawn_routes: vec![], - path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, - next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), - prefixes: vec![prefix.clone()], - })], + path_attributes: with_origin_and_as_path(PathAttribute::MpReachNlri( + MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()), + prefixes: vec![prefix.into()], + }, + )), announced_routes: vec![], })) .await?; @@ -353,9 +525,8 @@ async fn test_rib_records_ipv6_withdraw() -> Result<()> { peer.send(&Message::Update(UpdateMessage { withdrawn_routes: vec![], path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, - prefixes: vec![prefix.clone()], + family: AfiSafi::IPV6_UNICAST, + prefixes: vec![prefix.into()], })], announced_routes: vec![], })) @@ -363,11 +534,11 @@ async fn test_rib_records_ipv6_withdraw() -> Result<()> { let event = tokio::time::timeout(Duration::from_secs(1), events.recv()).await??; assert!( - matches!(&event, RibEvent::Withdrawn { prefix: p, .. } if p == &prefix), + matches!(&event, RouteChange::Withdrawn { prefix: p, .. } if p == &prefix), "Expected Withdrawn for 2001:db8::/32, got {event:?}", ); - let snapshot = server.adj_rib_in.snapshot().await; + let snapshot = server.received_routes(); assert!( !snapshot.iter().any(|e| e.prefix == prefix), "2001:db8::/32 should have been removed from RIB snapshot", diff --git a/crates/integration-tests/tests/best_route.rs b/crates/integration-tests/tests/best_route.rs new file mode 100644 index 0000000..b334fa3 --- /dev/null +++ b/crates/integration-tests/tests/best_route.rs @@ -0,0 +1,239 @@ +//! End-to-end tests of route selection: routes for one prefix from several +//! peers, which of them the server chooses, and how that is shown. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::{RouteChange, TestServer}; +use bgp_packet::message::{ + AsPathAttribute, Message, NextHopPathAttribute, OriginPathAttribute, PathAttribute, + UpdateMessage, +}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use eyre::Result; +use serde_json::Value; + +const LOCAL_ASN: u32 = 65000; +const TIMEOUT: Duration = Duration::from_secs(5); + +fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +/// A session from the peer numbered `number`, in AS 65000 + `number`. +async fn peer(server: &TestServer, number: u8) -> Result { + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake( + LOCAL_ASN + u32::from(number), + Ipv4Addr::new(10, 0, 0, number), + 90, + ) + .await?; + Ok(peer) +} + +fn announcement(prefixes: &[IpPrefix], as_path: &[u32]) -> Message { + Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(as_path.to_vec()), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + ], + announced_routes: Nlri::plain(prefixes.iter().copied()), + }) +} + +fn withdrawal(prefixes: &[IpPrefix]) -> Message { + Message::Update(UpdateMessage { + withdrawn_routes: Nlri::plain(prefixes.iter().copied()), + path_attributes: vec![], + announced_routes: vec![], + }) +} + +/// Waits until the table holds `routes` routes. +async fn wait_for_routes(server: &TestServer, routes: usize) -> Result<()> { + tokio::time::timeout(TIMEOUT, async { + while server.rib.len() != routes { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + Ok(()) +} + +/// The AS path of the route the server holds as best for `prefix`. +fn best_path(server: &TestServer, prefix: &IpPrefix) -> Option> { + let best = server + .received_routes() + .into_iter() + .find(|route| route.prefix == *prefix && route.best)?; + best.attributes + .iter() + .find_map(|attribute| match attribute { + PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()), + _ => None, + }) +} + +async fn get_json(client: &reqwest::Client, url: &str) -> Result { + Ok(client + .get(url) + .send() + .await? + .error_for_status()? + .json() + .await?) +} + +/// Two peers offer the same prefix. The route with the shorter AS path is +/// chosen; when it goes, the other is. +#[tokio::test] +async fn test_the_best_route_is_chosen_and_replaced_when_it_goes() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + let mut changes = server.route_changes(); + let p = prefix("203.0.113.0/24"); + + let mut far = peer(&server, 1).await?; + far.send(&announcement(&[p], &[65001, 64999, 64998])) + .await?; + wait_for_routes(&server, 1).await?; + assert_eq!(best_path(&server, &p), Some(vec![65001, 64999, 64998])); + + let mut near = peer(&server, 2).await?; + near.send(&announcement(&[p], &[65002, 64998])).await?; + wait_for_routes(&server, 2).await?; + assert_eq!(best_path(&server, &p), Some(vec![65002, 64998])); + + // Both routes are kept and shown; one is marked. + let status = get_json(&client, &format!("{base}/api/status")).await?; + assert_eq!(status["received_routes"], 2); + assert_eq!(status["received_prefixes"], 1); + let routes = get_json(&client, &format!("{base}/api/routes/received")).await?; + let best: Vec<&Value> = routes + .as_array() + .unwrap() + .iter() + .filter(|route| route["best"] == true) + .collect(); + assert_eq!(routes.as_array().unwrap().len(), 2); + assert_eq!(best.len(), 1); + assert_eq!(best[0]["peer_asn"], 65002); + + // The session with the best route ends: the other route takes over. + drop(near); + wait_for_routes(&server, 1).await?; + assert_eq!(best_path(&server, &p), Some(vec![65001, 64999, 64998])); + + far.send(&withdrawal(&[p])).await?; + wait_for_routes(&server, 0).await?; + assert_eq!(best_path(&server, &p), None); + + // A follower of the best routes is told of each change: the first + // route, the better one, back to the first, none. + let mut seen = Vec::new(); + while seen.last() != Some(&RouteChange::Withdrawn { prefix: p }) { + seen.push(tokio::time::timeout(TIMEOUT, changes.recv()).await??); + } + assert!( + seen.iter() + .all(|change| matches!(change, RouteChange::Announced { prefix, .. } | RouteChange::Withdrawn { prefix } if *prefix == p)), + "{seen:?}" + ); + Ok(()) +} + +/// The event stream reports changes of best route, and only those. +#[tokio::test] +async fn test_changes_of_best_route_are_on_the_event_stream() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let p = prefix("203.0.113.0/24"); + + // A route that is there before the client connects is not an event. + let mut first = peer(&server, 1).await?; + first + .send(&announcement(&[prefix("198.51.100.0/24")], &[65001])) + .await?; + wait_for_routes(&server, 1).await?; + + let mut stream = reqwest::Client::new() + .get(format!("{base}/api/events")) + .send() + .await? + .error_for_status()?; + + first.send(&announcement(&[p], &[65001, 64998])).await?; + wait_for_routes(&server, 2).await?; + first.send(&withdrawal(&[p])).await?; + wait_for_routes(&server, 1).await?; + + let mut text = String::new(); + let mut events: Vec = Vec::new(); + tokio::time::timeout(TIMEOUT, async { + while !events.iter().any(|event| event["type"] == "best_withdrawn") { + let chunk = stream + .chunk() + .await? + .ok_or_else(|| eyre::eyre!("stream ended"))?; + text.push_str(std::str::from_utf8(&chunk)?); + events = text + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .filter_map(|data| serde_json::from_str(data.trim()).ok()) + .collect(); + } + Ok::<_, eyre::Error>(()) + }) + .await??; + + let about_routes: Vec<&Value> = events + .iter() + .filter(|event| { + event["type"] + .as_str() + .is_some_and(|t| t.starts_with("best_")) + }) + .collect(); + // The announcement and the withdrawal may have been read as one change + // or as two; either way nothing is said of the route that was there + // from the start, and the last word is that the prefix is gone. + assert!(about_routes.len() <= 2, "{about_routes:?}"); + for event in &about_routes { + let named = event["prefix"] + .as_str() + .or(event["route"]["prefix"].as_str()); + assert_eq!(named, Some("203.0.113.0/24"), "{event}"); + } + assert_eq!(about_routes.last().unwrap()["type"], "best_withdrawn"); + Ok(()) +} + +/// The table can be far too large to send whole: the API gives a part of +/// it, and says how much there is. +#[tokio::test] +async fn test_received_routes_are_returned_up_to_a_limit() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let prefixes: Vec = (0..40).map(|n| prefix(&format!("10.{n}.0.0/16"))).collect(); + let mut peer = peer(&server, 1).await?; + peer.send(&announcement(&prefixes, &[65001])).await?; + wait_for_routes(&server, 40).await?; + + let count = |routes: Value| routes.as_array().unwrap().len(); + let url = format!("{base}/api/routes/received"); + assert_eq!(count(get_json(&client, &url).await?), 40); + assert_eq!( + count(get_json(&client, &format!("{url}?limit=7")).await?), + 7 + ); + let status = get_json(&client, &format!("{base}/api/status")).await?; + assert_eq!(status["received_routes"], 40); + Ok(()) +} diff --git a/crates/integration-tests/tests/collision.rs b/crates/integration-tests/tests/collision.rs new file mode 100644 index 0000000..192d37a --- /dev/null +++ b/crates/integration-tests/tests/collision.rs @@ -0,0 +1,315 @@ +//! Connection collision detection (RFC 4271 §6.8) against a live server. +//! +//! The test server's BGP Identifier is 127.0.0.1. A test peer picks an +//! identifier above or below that to steer which connection must survive. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::errors::{BgpError, CeaseSubcode, OpenMsgSubcode}; +use bgp_packet::message::{ + AsPathAttribute, Message, NextHopPathAttribute, NotificationMessage, OriginPathAttribute, + PathAttribute, UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use bgp_server::collision::Direction; +use bgp_server::config::PeerConfig; +use bgp_server::family::FamilySet; +use eyre::Result; +use tokio::net::TcpListener; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +/// Above the server's 127.0.0.1. +const HIGH_ID: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1); +/// Below the server's 127.0.0.1. +const LOW_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + +async fn server_dialing(listener: &TcpListener) -> Result { + let endpoint: SocketAddr = listener.local_addr()?; + TestServer::start_with(LOCAL_ASN, |c| { + c.connect_retry = Duration::from_millis(200); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint, + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path: None, + }]; + }) + .await +} + +/// Exchanges OPENs only, leaving the server's side in OpenConfirm (it has +/// sent its KEEPALIVE and is waiting for ours). +async fn open_only(peer: &mut TestPeer, router_id: Ipv4Addr) -> Result<()> { + peer.send_open(PEER_ASN, router_id, 90).await?; + peer.recv_open().await?; + Ok(()) +} + +/// Finishes the handshake begun by [`open_only`]. +async fn confirm(peer: &mut TestPeer) -> Result<()> { + match tokio::time::timeout(TIMEOUT, peer.recv()).await?? { + Message::KeepAlive => {} + other => eyre::bail!("expected KEEPALIVE, got {other:?}"), + } + peer.send(&Message::KeepAlive).await +} + +/// Reads until a NOTIFICATION arrives, skipping KEEPALIVEs. +async fn expect_notification(peer: &mut TestPeer) -> Result { + tokio::time::timeout(TIMEOUT, async { + loop { + match peer.recv().await? { + Message::Notification(n) => return Ok(n), + Message::KeepAlive => {} + other => eyre::bail!("expected NOTIFICATION, got {other:?}"), + } + } + }) + .await? +} + +fn is_collision_cease(n: &NotificationMessage) -> bool { + matches!( + n.error, + BgpError::Cease(CeaseSubcode::ConnCollisionResolution) + ) +} + +async fn wait_for_sessions(server: &TestServer, n: usize) -> Result<()> { + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().len() != n { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) +} + +/// Peer has the higher identifier, so the connection the *peer* initiated +/// must survive: the server closes its own outbound connection. +#[tokio::test] +async fn test_simultaneous_open_peer_higher_keeps_inbound() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener).await?; + + // Server's outbound connection reaches OpenConfirm. + let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + let mut dialled = TestPeer::from_stream(stream); + open_only(&mut dialled, HIGH_ID).await?; + + // The peer now also dials the server. + let mut inbound = TestPeer::connect(server.addr).await?; + open_only(&mut inbound, HIGH_ID).await?; + + let notif = expect_notification(&mut dialled).await?; + assert!(is_collision_cease(¬if), "{notif:?}"); + assert!(dialled.is_closed().await); + + confirm(&mut inbound).await?; + wait_for_sessions(&server, 1).await?; + let session = server.registry.list().remove(0); + assert_eq!(session.direction, Direction::Inbound); + assert_eq!(session.name.as_deref(), Some("r1")); + + // The survivor is fully functional. + server + .local_rib + .announce(LocalRoute::new( + IpPrefix::try_from("10.100.1.0/24")?, + LocalRouteAttrs::default(), + )) + .await?; + let update = tokio::time::timeout(TIMEOUT, inbound.recv_update()).await??; + assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); + + // The dialer notices the surviving session and does not open another. + let extra = tokio::time::timeout(Duration::from_millis(700), listener.accept()).await; + assert!( + extra.is_err(), + "dialer opened a second connection despite an established session" + ); + Ok(()) +} + +/// Peer has the lower identifier, so the connection the *server* initiated +/// must survive: the peer's inbound connection is refused. +#[tokio::test] +async fn test_simultaneous_open_peer_lower_keeps_outbound() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener).await?; + + let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + let mut dialled = TestPeer::from_stream(stream); + open_only(&mut dialled, LOW_ID).await?; + + let mut inbound = TestPeer::connect(server.addr).await?; + open_only(&mut inbound, LOW_ID).await?; + + let notif = expect_notification(&mut inbound).await?; + assert!(is_collision_cease(¬if), "{notif:?}"); + assert!(inbound.is_closed().await); + + confirm(&mut dialled).await?; + wait_for_sessions(&server, 1).await?; + assert_eq!(server.registry.list()[0].direction, Direction::Outbound); + Ok(()) +} + +/// The order in which the two OPENs are processed must not change the +/// outcome: here the inbound connection reaches OpenConfirm first. +#[tokio::test] +async fn test_outcome_is_independent_of_arrival_order() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener).await?; + + let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + let mut dialled = TestPeer::from_stream(stream); + + // Inbound first this time; peer is lower so outbound must still win. + let mut inbound = TestPeer::connect(server.addr).await?; + open_only(&mut inbound, LOW_ID).await?; + open_only(&mut dialled, LOW_ID).await?; + + let notif = expect_notification(&mut inbound).await?; + assert!(is_collision_cease(¬if), "{notif:?}"); + + confirm(&mut dialled).await?; + wait_for_sessions(&server, 1).await?; + assert_eq!(server.registry.list()[0].direction, Direction::Outbound); + Ok(()) +} + +/// RFC default: a collision with an Established session closes the new +/// connection, whatever the identifiers say. +#[tokio::test] +async fn test_established_session_wins_by_default() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + + let mut first = TestPeer::connect(server.addr).await?; + first.handshake(PEER_ASN, HIGH_ID, 90).await?; + wait_for_sessions(&server, 1).await?; + + let mut second = TestPeer::connect(server.addr).await?; + open_only(&mut second, HIGH_ID).await?; + let notif = expect_notification(&mut second).await?; + assert!(is_collision_cease(¬if), "{notif:?}"); + + // The original is untouched. + assert_eq!(server.registry.list().len(), 1); + server + .local_rib + .announce(LocalRoute::new( + IpPrefix::try_from("10.100.1.0/24")?, + LocalRouteAttrs::default(), + )) + .await?; + let update = tokio::time::timeout(TIMEOUT, first.recv_update()).await??; + assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); + Ok(()) +} + +/// With collision_detect_established_state, a peer that restarts and dials +/// again replaces the stale session at once, and the routes learned over the +/// old session are flushed. +#[tokio::test] +async fn test_restarted_peer_replaces_stale_session_when_enabled() -> Result<()> { + let server = + TestServer::start_with(LOCAL_ASN, |c| c.collision_detect_established_state = true).await?; + + let mut stale = TestPeer::connect(server.addr).await?; + stale.handshake(PEER_ASN, HIGH_ID, 90).await?; + wait_for_sessions(&server, 1).await?; + stale + .send(&Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![PEER_ASN]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(10, 0, 0, 9))), + ], + announced_routes: vec![IpPrefix::try_from("203.0.113.0/24")?.into()], + })) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.received_routes().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + let stale_addr = server.registry.list()[0].peer.addr; + + // "Restart": a fresh connection from the same router. + let mut fresh = TestPeer::connect(server.addr).await?; + fresh.handshake(PEER_ASN, HIGH_ID, 90).await?; + + let notif = expect_notification(&mut stale).await?; + assert!(is_collision_cease(¬if), "{notif:?}"); + + tokio::time::timeout(TIMEOUT, async { + loop { + let sessions = server.registry.list(); + if sessions.len() == 1 && sessions[0].peer.addr != stale_addr { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert!( + server.received_routes().is_empty(), + "stale routes were not flushed" + ); + Ok(()) +} + +/// Different routers (different identifiers) never collide. +#[tokio::test] +async fn test_distinct_identifiers_do_not_collide() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut a = TestPeer::connect(server.addr).await?; + a.handshake(PEER_ASN, HIGH_ID, 90).await?; + let mut b = TestPeer::connect(server.addr).await?; + b.handshake(PEER_ASN, LOW_ID, 90).await?; + wait_for_sessions(&server, 2).await?; + Ok(()) +} + +/// A failed connection frees its slot: the same router can come back. +#[tokio::test] +async fn test_identifier_is_released_when_connection_ends() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut first = TestPeer::connect(server.addr).await?; + first.handshake(PEER_ASN, HIGH_ID, 90).await?; + wait_for_sessions(&server, 1).await?; + drop(first); + wait_for_sessions(&server, 0).await?; + + let mut second = TestPeer::connect(server.addr).await?; + second.handshake(PEER_ASN, HIGH_ID, 90).await?; + wait_for_sessions(&server, 1).await?; + Ok(()) +} + +/// A peer claiming our own identifier is refused outright. +#[tokio::test] +async fn test_peer_with_our_identifier_is_refused() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = TestPeer::connect(server.addr).await?; + open_only(&mut peer, Ipv4Addr::new(127, 0, 0, 1)).await?; + let notif = expect_notification(&mut peer).await?; + assert!( + matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadBgpId)), + "{notif:?}" + ); + Ok(()) +} diff --git a/crates/integration-tests/tests/dialer.rs b/crates/integration-tests/tests/dialer.rs new file mode 100644 index 0000000..d4e751e --- /dev/null +++ b/crates/integration-tests/tests/dialer.rs @@ -0,0 +1,319 @@ +//! The active side: the server dials its configured peers and keeps +//! retrying. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::errors::{BgpError, OpenMsgSubcode}; +use bgp_packet::message::Message; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use bgp_server::config::PeerConfig; +use bgp_server::family::FamilySet; +use bgp_server::session::DialState; +use eyre::Result; +use tokio::net::TcpListener; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +/// Starts a server configured to dial `listener`'s address as peer "r1". +async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result { + let endpoint: SocketAddr = listener.local_addr()?; + TestServer::start_with(LOCAL_ASN, |c| { + c.connect_retry = Duration::from_millis(200); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint, + remote_asn, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path: None, + }]; + }) + .await +} + +async fn accept(listener: &TcpListener) -> Result { + let (stream, _) = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + Ok(TestPeer::from_stream(stream)) +} + +#[tokio::test] +async fn test_server_dials_configured_peer_and_establishes() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener, PEER_ASN).await?; + + let mut peer = accept(&listener).await?; + // Both sides send OPEN on connect; the test peer's handshake order works + // whichever side opened TCP. + let open = peer + .handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + assert_eq!(open.asn, LOCAL_ASN as u16); + + let mut events = server.registry.subscribe(); + // Either the Up event is still queued or the session is already listed. + let listed = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some(s) = server.registry.list().first().cloned() { + return s; + } + let _ = events.recv().await; + } + }) + .await?; + assert_eq!(listed.name.as_deref(), Some("r1")); + assert_eq!(listed.peer.asn, PEER_ASN); + assert_eq!(listed.peer.addr, listener.local_addr()?); + + // Configured view reflects it; the dialer is not in backoff. + let (cfg, state) = server + .registry + .configured_peers() + .into_iter() + .next() + .unwrap(); + assert_eq!(cfg.name, "r1"); + assert!( + matches!(state, DialState::Connecting | DialState::Idle), + "{state:?}" + ); + + // Local routes flow over the dialled session too. + server + .local_rib + .announce(LocalRoute::new( + IpPrefix::try_from("10.100.1.0/24")?, + LocalRouteAttrs::default(), + )) + .await?; + let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes[0].to_string(), "10.100.1.0/24"); + Ok(()) +} + +#[tokio::test] +async fn test_server_reconnects_after_peer_closes() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener, PEER_ASN).await?; + + let mut first = accept(&listener).await?; + first + .handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + drop(first); + + // Session goes away, then the dialer comes back. + tokio::time::timeout(TIMEOUT, async { + while !server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + let (_, state) = server + .registry + .configured_peers() + .into_iter() + .next() + .unwrap(); + assert!(matches!(state, DialState::Backoff { .. }), "{state:?}"); + + let mut second = accept(&listener).await?; + second + .handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) +} + +#[tokio::test] +async fn test_wrong_asn_is_refused_with_bad_peer_as() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let _server = server_dialing(&listener, PEER_ASN).await?; + + let mut peer = accept(&listener).await?; + peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + let _ = peer.recv_open().await?; + let notif = tokio::time::timeout(TIMEOUT, async { + loop { + match peer.recv().await? { + Message::Notification(n) => return Ok::<_, eyre::Error>(n), + Message::KeepAlive => {} + other => eyre::bail!("unexpected {other:?}"), + } + } + }) + .await??; + assert!( + matches!(notif.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), + "{notif:?}" + ); + + // The dialer retries rather than giving up. + let _again = accept(&listener).await?; + Ok(()) +} + +/// An inbound connection from a configured peer's address is also checked +/// against the configured ASN and gets the configured name. +#[tokio::test] +async fn test_inbound_from_configured_address_uses_config() -> Result<()> { + // Configure a peer at the loopback address on a port nobody listens on, + // so the dialer never succeeds; then connect inbound from loopback. + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.connect_retry = Duration::from_secs(60); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1), + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: FamilySet::supported(), + add_path: None, + }]; + }) + .await?; + + // Wrong ASN inbound → refused. + let mut bad = TestPeer::connect(server.addr).await?; + bad.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90).await?; + let _ = bad.recv_open().await?; + let msg = tokio::time::timeout(TIMEOUT, bad.recv()).await??; + assert!( + matches!(&msg, Message::Notification(n) if matches!(n.error, BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs))), + "{msg:?}" + ); + + // Right ASN inbound → established, named. + let mut good = TestPeer::connect(server.addr).await?; + good.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + let listed = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some(s) = server.registry.list().first().cloned() { + return s; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert_eq!(listed.name.as_deref(), Some("r1")); + Ok(()) +} + +/// With `local_address` set the connection must come from that address (the +/// peer may identify us by it), and an address the host does not have is +/// reported as the reason for backing off rather than silently ignored. +#[tokio::test] +async fn test_local_address_is_used_as_the_source() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let endpoint: SocketAddr = listener.local_addr()?; + let _server = TestServer::start_with(LOCAL_ASN, |c| { + c.connect_retry = Duration::from_millis(200); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint, + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: Some(Ipv4Addr::LOCALHOST.into()), + families: FamilySet::supported(), + add_path: None, + }]; + }) + .await?; + let (_stream, from) = tokio::time::timeout(TIMEOUT, listener.accept()).await??; + assert_eq!(from.ip(), std::net::IpAddr::from(Ipv4Addr::LOCALHOST)); + + // 192.0.2.1 is documentation space; no host has it. + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.connect_retry = Duration::from_millis(200); + c.peers = vec![PeerConfig { + name: "r1".into(), + endpoint, + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: Some(Ipv4Addr::new(192, 0, 2, 1).into()), + families: FamilySet::supported(), + add_path: None, + }]; + }) + .await?; + let reason = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some((_, DialState::Backoff { last_error, .. })) = + server.registry.configured_peers().into_iter().next() + { + return last_error; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await?; + assert!(reason.contains("local_address 192.0.2.1"), "{reason}"); + Ok(()) +} + +/// A peer that takes the connection and then refuses the session, here +/// because it is not the AS we are configured to expect, will refuse it +/// again. It is dialled ever more slowly, like a peer that cannot be +/// reached: connecting used to count as success and reset the wait, so +/// such a peer was dialled every few seconds for ever. +#[tokio::test] +async fn test_a_peer_that_refuses_the_session_is_dialled_ever_more_slowly() -> Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let server = server_dialing(&listener, PEER_ASN).await?; + + let mut waits = Vec::new(); + for _ in 0..3 { + let mut peer = accept(&listener).await?; + peer.send_open(65099, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + let _ = peer.recv_open().await?; + let refusal = tokio::time::timeout(TIMEOUT, peer.recv()).await??; + assert!( + matches!(&refusal, Message::Notification(n) if n.error == BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs)), + "{refusal:?}" + ); + drop(peer); + + // How long the dialer says it will wait before trying again. + let wait = tokio::time::timeout(TIMEOUT, async { + loop { + if let Some((_, DialState::Backoff { until, last_error })) = + server.registry.configured_peers().into_iter().next() + { + assert!(last_error.contains("AS65099"), "{last_error}"); + return until + .duration_since(std::time::SystemTime::now()) + .unwrap_or_default(); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await?; + waits.push(wait); + } + // 200 ms, then 400, then 800, less what passed before each was read. + assert!( + waits[2] > Duration::from_millis(500), + "the waits were {waits:?}" + ); + Ok(()) +} diff --git a/crates/integration-tests/tests/failure.rs b/crates/integration-tests/tests/failure.rs new file mode 100644 index 0000000..47fd29f --- /dev/null +++ b/crates/integration-tests/tests/failure.rs @@ -0,0 +1,87 @@ +//! What the server does when a part of it that it cannot do without fails. +//! +//! The local RIB's actor stops only by panicking. Before, nothing noticed: +//! sessions stayed up, nothing was announced any more, and the status page +//! showed an empty table. Now the server tells its peers it is going and +//! stops with an error, for whatever supervises it to restart it. + +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::errors::{BgpError, CeaseSubcode}; +use bgp_packet::message::Message; +use bgp_rib::RibStopped; +use bgp_server::error::ServerError; +use eyre::{Result, bail}; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +async fn established(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) +} + +/// Reads until the server's NOTIFICATION, skipping KEEPALIVEs. +async fn farewell(peer: &mut TestPeer) -> Result { + tokio::time::timeout(TIMEOUT, async { + loop { + match peer.recv().await? { + Message::KeepAlive => {} + Message::Notification(n) => return Ok(n.error), + other => bail!("expected NOTIFICATION, got {other:?}"), + } + } + }) + .await? +} + +#[tokio::test] +async fn test_losing_the_local_rib_stops_the_server_and_tells_the_peers() -> Result<()> { + let mut server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let mut peer = established(&server).await?; + + server.local_rib.stop_for_testing(); + + let outcome = tokio::time::timeout(TIMEOUT, server.stopped()).await?; + assert!( + matches!(outcome, Err(ServerError::RibStopped(RibStopped { rib })) if rib == "local RIB"), + "{outcome:?}" + ); + assert_eq!( + farewell(&mut peer).await?, + BgpError::Cease(CeaseSubcode::AdminShutdown) + ); + + // The status interface says the table cannot be read, where it used to + // show an empty one. + let client = reqwest::Client::new(); + for path in ["/api/status", "/api/routes/local", "/metricz"] { + let response = client.get(format!("{base}{path}")).send().await?; + assert_eq!(response.status(), 503, "{path}"); + assert_eq!( + response.text().await?, + "the local RIB has stopped", + "{path}" + ); + } + // What does not need that table still answers. + let response = client + .get(format!("{base}/api/routes/received")) + .send() + .await?; + assert_eq!(response.status(), 200); + Ok(()) +} diff --git a/crates/integration-tests/tests/http_api.rs b/crates/integration-tests/tests/http_api.rs new file mode 100644 index 0000000..31fc7c2 --- /dev/null +++ b/crates/integration-tests/tests/http_api.rs @@ -0,0 +1,303 @@ +//! 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, LargeCommunity, Message, NextHopPathAttribute, + OriginPathAttribute, PathAttribute, UpdateMessage, +}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +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 config = get_json(&client, &format!("{base}/api/config")).await?; + assert_eq!(config["path"], "test.textpb"); + assert_eq!(config["text"], format!("local_asn: {LOCAL_ASN}\n")); + + 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); + assert!(status["started_at_unix"].as_u64().unwrap() > 1_700_000_000); + assert!(!status["hostname"].as_str().unwrap().is_empty()); + + 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); + assert_eq!( + peers[0]["address_families"], + serde_json::json!(["IPv4 unicast", "IPv6 unicast"]) + ); + Ok(()) +} + +/// Originated routes are listed with their attributes as text. +#[tokio::test] +async fn test_originated_routes_are_listed() -> Result<()> { + let attrs = LocalRouteAttrs { + prepend: 1, + med: Some(10), + communities: vec![(65000, 100), (65535, 65281)], + large_communities: vec![LargeCommunity { + global_admin: 65000, + data_1: 1, + data_2: 2, + }], + ..Default::default() + }; + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.originate = vec![LocalRoute::new(prefix("10.100.1.0/24"), attrs)]; + }) + .await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let local = get_json(&client, &format!("{base}/api/routes/local")).await?; + assert_eq!(local.as_array().unwrap().len(), 1); + assert_eq!(local[0]["prefix"], "10.100.1.0/24"); + assert_eq!(local[0]["origin"], "IGP"); + assert_eq!(local[0]["prepend"], 1); + assert_eq!(local[0]["med"], 10); + assert_eq!(local[0]["communities"], json!(["65000:100", "65535:65281"])); + assert_eq!(local[0]["large_communities"], json!(["65000:1:2"])); + assert_eq!( + get_json(&client, &format!("{base}/api/status")).await?["local_routes"], + 1 + ); + Ok(()) +} + +/// The API only reports. Nothing reachable over HTTP changes what the +/// router announces. +#[tokio::test] +async fn test_api_is_read_only() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let body = json!({ "prefix": "10.100.1.0/24" }); + for path in [ + "/api/routes/local", + "/api/routes/received", + "/api/peers", + "/api/status", + ] { + let post = client + .post(format!("{base}{path}")) + .json(&body) + .send() + .await?; + assert_eq!(post.status(), 405, "POST {path}"); + let delete = client + .delete(format!("{base}{path}")) + .json(&body) + .send() + .await?; + assert_eq!(delete.status(), 405, "DELETE {path}"); + } + assert!(server.local_rib.snapshot().await.unwrap().is_empty()); + 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").into()], + })) + .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(LocalRoute::new(prefix("10.100.1.0/24"), 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_statusz_page_is_served() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let (base, _http) = server.serve_http().await?; + let client = reqwest::Client::new(); + + let resp = client.get(format!("{base}/statusz")).send().await?; + assert_eq!(resp.status(), 200); + assert!( + resp.headers()["content-type"] + .to_str()? + .starts_with("text/html") + ); + let body = resp.text().await?; + assert!(body.contains("Status for ferrix_bgp")); + assert!(body.contains("/api/events")); + + // The root redirects to the page. + let no_redirect = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = no_redirect.get(format!("{base}/")).send().await?; + assert_eq!(resp.status(), 307); + assert_eq!(resp.headers()["location"], "/statusz"); + Ok(()) +} + +#[tokio::test] +async fn test_metricz() -> 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_rib_routes").is_some(), "{text}"); + Ok(()) +} diff --git a/crates/integration-tests/tests/interop.rs b/crates/integration-tests/tests/interop.rs new file mode 100644 index 0000000..d7d410c --- /dev/null +++ b/crates/integration-tests/tests/interop.rs @@ -0,0 +1,148 @@ +//! Interoperability with what real routers put on the wire. + +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::{Message, PathAttribute}; +use bgp_packet::open::{Capability, OpenOption}; +use bgp_primitives::constants::{Afi, AfiSafi, Safi}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use eyre::Result; + +const TIMEOUT: Duration = Duration::from_secs(5); + +/// The OPEN a BIRD 2 router sends for an IPv6-only session from AS210036: +/// one optional parameter carrying MP(IPv6 unicast), route refresh, +/// extended message, graceful restart (aware only), 4-octet AS, enhanced +/// route refresh and long-lived graceful restart (aware only). +#[rustfmt::skip] +const BIRD_OPEN: &[u8] = &[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x37, 0x01, // length 55, type OPEN + 0x04, // version + 0x5b, 0xa0, // AS_TRANS + 0x00, 0xf0, // hold time 240 + 0x0a, 0xff, 0x00, 0x01, // router id 10.255.0.1 + 0x1a, // optional parameters length 26 + 0x02, 0x18, // capabilities, 24 bytes + 0x01, 0x04, 0x00, 0x02, 0x00, 0x01, // MP: IPv6 unicast + 0x02, 0x00, // route refresh + 0x06, 0x00, // extended message + 0x40, 0x02, 0x00, 0x00, // graceful restart, no address families + 0x41, 0x04, 0x00, 0x03, 0x34, 0x74, // 4-octet AS 210036 + 0x46, 0x00, // enhanced route refresh + 0x47, 0x00, // long-lived graceful restart, none +]; + +/// IPv6 End-of-RIB: an UPDATE whose only attribute is an empty +/// MP_UNREACH_NLRI for IPv6 unicast (RFC 4724 §2). +#[rustfmt::skip] +const IPV6_END_OF_RIB: &[u8] = &[ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x1e, 0x02, // length 30, type UPDATE + 0x00, 0x00, // no withdrawn routes + 0x00, 0x07, // 7 bytes of path attributes + 0x90, 0x0f, 0x00, 0x03, // optional + extended length, MP_UNREACH_NLRI, len 3 + 0x00, 0x02, 0x01, // IPv6 unicast, no prefixes +]; + +#[tokio::test] +async fn test_server_advertises_ipv4_and_ipv6_unicast() -> Result<()> { + let server = TestServer::start(65000).await?; + let mut peer = TestPeer::connect(server.addr).await?; + let open = peer.recv_open().await?; + let caps: Vec<&Capability> = open + .options + .iter() + .flat_map(|o| match o { + OpenOption::Capabilities(c) => c.iter(), + }) + .collect(); + for afi in [Afi::IPV4, Afi::IPV6] { + assert!( + caps.iter().any(|c| matches!(c, Capability::MultiProtocol { afi: a, safi: Safi::UNICAST } if *a == afi)), + "missing MP capability for {afi:?}: {caps:?}" + ); + } + Ok(()) +} + +#[tokio::test] +async fn test_bird_open_and_end_of_rib_are_accepted() -> Result<()> { + let server = TestServer::start(4200000001).await?; + let mut peer = TestPeer::connect(server.addr).await?; + + peer.send_raw(BIRD_OPEN).await?; + peer.recv_open().await?; + match tokio::time::timeout(TIMEOUT, peer.recv()).await?? { + Message::KeepAlive => {} + other => eyre::bail!("server did not accept BIRD's OPEN: {other:?}"), + } + peer.send(&Message::KeepAlive).await?; + + tokio::time::timeout(TIMEOUT, async { + while server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + let session = server.registry.list().remove(0); + assert_eq!( + session.peer.asn, 210036, + "4-octet ASN must come from the capability, not AS_TRANS" + ); + assert!(session.four_octet_asn); + + // End-of-RIB must neither break the session nor create a route. + peer.send_raw(IPV6_END_OF_RIB).await?; + peer.send(&Message::KeepAlive).await?; + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + server.registry.list().len(), + 1, + "session dropped after End-of-RIB" + ); + assert!(server.received_routes().is_empty()); + Ok(()) +} + +/// Over an IPv4 test transport an IPv6 route cannot be sent (no IPv6 next +/// hop), so this only checks the builder the advertiser uses: the UPDATE for +/// an IPv6 prefix carries MP_REACH_NLRI with an IPv6 next hop and no NEXT_HOP. +#[tokio::test] +async fn test_ipv6_prefix_update_shape() -> Result<()> { + let route = LocalRoute::new( + IpPrefix::try_from("2001:db8:1::/48")?, + LocalRouteAttrs::default(), + ); + let nh: std::net::Ipv6Addr = "fd00:179::2".parse()?; + let update = bgp_server::advertise::build_announce( + &route, + &bgp_server::advertise::Towards { + local_asn: 4200000001, + next_hop: nh.into(), + four_octet_asn: true, + ibgp: false, + }, + ) + .expect("ipv6 over ipv6"); + assert!(update.announced_routes.is_empty()); + assert!( + !update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::NextHop(_))) + ); + assert!( + update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::MpReachNlri(m) + if m.family == AfiSafi::IPV6_UNICAST && m.prefixes[0].to_string() == "2001:db8:1::/48")) + ); + let _ = Ipv4Addr::LOCALHOST; + Ok(()) +} diff --git a/crates/integration-tests/tests/negotiation.rs b/crates/integration-tests/tests/negotiation.rs new file mode 100644 index 0000000..debcead --- /dev/null +++ b/crates/integration-tests/tests/negotiation.rs @@ -0,0 +1,321 @@ +//! End-to-end tests for capability negotiation: what the two OPENs agree on +//! decides which routes are sent and accepted, and how they are encoded. + +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::TestServer; +use bgp_packet::errors::{BgpError, OpenMsgSubcode}; +use bgp_packet::message::{ + AsPathAttribute, Message, MpReachNlriPathAttribute, NextHopPathAttribute, NlriNextHop, + OriginPathAttribute, PathAttribute, UpdateMessage, +}; +use bgp_packet::open::Capability; +use bgp_primitives::constants::{Afi, AfiSafi, Safi}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; +use bgp_server::config::PeerConfig; +use bgp_server::family::{FamilySet, SUPPORTED}; +use eyre::{Result, bail}; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const PEER_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +const TIMEOUT: Duration = Duration::from_secs(5); + +fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +fn only(family: AfiSafi) -> FamilySet { + [family].into_iter().collect() +} + +fn four_octet() -> Capability { + Capability::FourByteAsn { asn: PEER_ASN } +} + +async fn wait_for_session(server: &TestServer) -> Result { + Ok(tokio::time::timeout(TIMEOUT, async { + loop { + if let Some(session) = server.registry.list().into_iter().next() { + return session; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?) +} + +/// An UPDATE announcing one IPv4 route in the body and one IPv6 route in +/// MP_REACH_NLRI. +fn dual_stack_update() -> UpdateMessage { + UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![PEER_ASN]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::MpReachNlri(MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + prefixes: vec![prefix("2001:db8:1::/48").into()], + }), + ], + announced_routes: vec![prefix("192.0.2.0/24").into()], + } +} + +/// The session carries the families both OPENs offered, and the server's +/// OPEN offers every family by default. +#[tokio::test] +async fn test_families_are_the_ones_both_sides_offered() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = TestPeer::connect(server.addr).await?; + let open = peer + .handshake_with( + PEER_ASN, + PEER_ID, + 90, + vec![ + four_octet(), + Capability::multiprotocol(AfiSafi::IPV4_UNICAST), + ], + ) + .await?; + + for family in SUPPORTED { + assert!( + open.capabilities() + .any(|c| *c == Capability::multiprotocol(family)), + "the server's OPEN offers {family}" + ); + } + let session = wait_for_session(&server).await?; + assert_eq!(session.families, only(AfiSafi::IPV4_UNICAST)); + Ok(()) +} + +/// A route of a family the peer did not offer is not accepted from it. The +/// rest of the UPDATE is, and the session stays up. +#[tokio::test] +async fn test_routes_of_a_family_not_negotiated_are_not_accepted() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake_with( + PEER_ASN, + PEER_ID, + 90, + vec![ + four_octet(), + Capability::multiprotocol(AfiSafi::IPV4_UNICAST), + ], + ) + .await?; + wait_for_session(&server).await?; + + peer.send(&Message::Update(dual_stack_update())).await?; + + let routes = tokio::time::timeout(TIMEOUT, async { + loop { + let routes = server.received_routes(); + if !routes.is_empty() { + return routes; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + // One UPDATE is applied as a whole, so the IPv6 route is not on its way. + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].prefix, prefix("192.0.2.0/24")); + assert_eq!(server.registry.list().len(), 1, "the session survives"); + Ok(()) +} + +/// A route of a family the peer did not offer is not sent to it. +#[tokio::test] +async fn test_routes_of_a_family_not_negotiated_are_not_sent() -> Result<()> { + let server = TestServer::start_with(LOCAL_ASN, |c| { + c.originate = vec![LocalRoute::new( + prefix("10.100.0.0/16"), + LocalRouteAttrs::default(), + )]; + }) + .await?; + + // This peer wants IPv6 only; the server has an IPv4 route. + let mut v6_only = TestPeer::connect(server.addr).await?; + v6_only + .handshake_with( + PEER_ASN, + PEER_ID, + 90, + vec![ + four_octet(), + Capability::multiprotocol(AfiSafi::IPV6_UNICAST), + ], + ) + .await?; + let session = wait_for_session(&server).await?; + assert_eq!(session.families, only(AfiSafi::IPV6_UNICAST)); + + // A second peer that takes IPv4 is sent the route. The advertiser + // handles sessions in the order they came up, so by the time this one + // has its route the first has been dealt with. + let mut dual = TestPeer::connect(server.addr).await?; + dual.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90) + .await?; + let update = tokio::time::timeout(TIMEOUT, dual.recv_update()).await??; + assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]); + + let nothing = tokio::time::timeout(Duration::from_millis(200), v6_only.recv_update()).await; + assert!(nothing.is_err(), "got {nothing:?}"); + Ok(()) +} + +/// A configured peer is offered only the families configured for it, and a +/// peer that shares none of them is refused with the capability we needed. +#[tokio::test] +async fn test_no_family_in_common_refuses_the_session() -> Result<()> { + let server = TestServer::start_with(LOCAL_ASN, |c| { + // Inbound connections from this address inherit the peer's settings. + c.peers = vec![PeerConfig { + name: "v6-only".into(), + endpoint: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 1), + remote_asn: PEER_ASN, + hold_time_secs: 90, + local_address: None, + families: only(AfiSafi::IPV6_UNICAST), + add_path: None, + }]; + // Keep the dialer out of the way. + c.connect_retry = Duration::from_secs(3600); + }) + .await?; + + let mut peer = TestPeer::connect(server.addr).await?; + peer.send_open_with( + PEER_ASN, + PEER_ID, + 90, + vec![ + four_octet(), + Capability::multiprotocol(AfiSafi::IPV4_UNICAST), + ], + ) + .await?; + let open = peer.recv_open().await?; + let offered: Vec<_> = open + .capabilities() + .filter(|c| matches!(c, Capability::MultiProtocol { .. })) + .collect(); + assert_eq!(offered, [&Capability::multiprotocol(AfiSafi::IPV6_UNICAST)]); + + match tokio::time::timeout(TIMEOUT, peer.recv()).await?? { + Message::Notification(n) => { + assert_eq!( + n.error, + BgpError::OpenMsg(OpenMsgSubcode::UnsupportedCapability) + ); + // The Multiprotocol capability for IPv6 unicast. + assert_eq!(n.data, [1, 4, 0, 2, 0, 1]); + } + other => bail!("expected NOTIFICATION, got {other:?}"), + } + assert!(server.registry.list().is_empty()); + Ok(()) +} + +/// Real routers offer families we do not support. That must not stop +/// the families we do share from working. +#[tokio::test] +async fn test_a_peer_offering_an_unsupported_family_is_still_accepted() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake_with( + PEER_ASN, + PEER_ID, + 90, + vec![ + four_octet(), + Capability::multiprotocol(AfiSafi::new(Afi::L2VPN, Safi::EVPN)), + Capability::multiprotocol(AfiSafi::IPV4_UNICAST), + ], + ) + .await?; + + let session = wait_for_session(&server).await?; + assert_eq!(session.families, only(AfiSafi::IPV4_UNICAST)); + assert!( + session.four_octet_asn, + "capabilities after it are still read" + ); + Ok(()) +} + +/// A peer that offers no capabilities at all is a plain RFC 4271 speaker: +/// IPv4 unicast, two-octet AS numbers. Our four-octet AS number reaches it +/// as AS_TRANS in AS_PATH and in full in AS4_PATH (RFC 6793 §4.2.2). +#[tokio::test] +async fn test_a_peer_without_capabilities_gets_two_octet_as_paths() -> Result<()> { + const WIDE_ASN: u32 = 4_200_000_001; + let server = TestServer::start_with(WIDE_ASN, |c| { + c.originate = vec![LocalRoute::new( + prefix("10.100.0.0/16"), + LocalRouteAttrs::default(), + )]; + }) + .await?; + + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake_with(PEER_ASN, PEER_ID, 90, vec![]).await?; + let session = wait_for_session(&server).await?; + assert!(!session.four_octet_asn); + assert_eq!(session.families, only(AfiSafi::IPV4_UNICAST)); + + // The test peer now decodes AS_PATH two octets wide, as such a speaker + // would; an UPDATE encoded four octets wide would not parse. + let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??; + assert_eq!(update.announced_routes, vec![prefix("10.100.0.0/16")]); + assert!( + update + .path_attributes + .contains(&AsPathAttribute::from_asns(vec![23456])), + "{update:?}" + ); + let as4_path = update.path_attributes.iter().find_map(|a| match a { + PathAttribute::As4Path(path) => Some(path.segments[0].path.clone()), + _ => None, + }); + assert_eq!(as4_path, Some(vec![WIDE_ASN])); + + // And its own two-octet paths are read correctly. + peer.send(&Message::Update(UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![PEER_ASN, 64512]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + ], + announced_routes: vec![prefix("192.0.2.0/24").into()], + })) + .await?; + let routes = tokio::time::timeout(TIMEOUT, async { + loop { + let routes = server.received_routes(); + if !routes.is_empty() { + return routes; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + assert!( + routes[0] + .attributes + .contains(&AsPathAttribute::from_asns(vec![PEER_ASN, 64512])) + ); + Ok(()) +} diff --git a/crates/integration-tests/tests/update_errors.rs b/crates/integration-tests/tests/update_errors.rs new file mode 100644 index 0000000..46dfe99 --- /dev/null +++ b/crates/integration-tests/tests/update_errors.rs @@ -0,0 +1,228 @@ +//! End-to-end tests for errors in UPDATE messages (RFC 7606): most of them +//! cost the routes in the UPDATE, not the session. + +use std::net::Ipv4Addr; +use std::time::Duration; + +use bgp_integration_tests::test_peer::TestPeer; +use bgp_integration_tests::test_server::{ReceivedRoute, TestServer}; +use bgp_packet::errors::{BgpError, UpdateMsgSubcode}; +use bgp_packet::message::{LocalPrefPathAttribute, Message, PathAttribute}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_server::metrics::UPDATE_ERRORS; +use eyre::{Result, bail}; + +const LOCAL_ASN: u32 = 65000; +const PEER_ASN: u32 = 65001; +const TIMEOUT: Duration = Duration::from_secs(5); + +const WELL_KNOWN: u8 = 0x40; +const OPTIONAL_TRANSITIVE: u8 = 0xc0; + +fn attr(flags: u8, type_code: u8, value: &[u8]) -> Vec { + let mut wire = vec![flags, type_code, value.len() as u8]; + wire.extend_from_slice(value); + wire +} + +fn origin() -> Vec { + attr(WELL_KNOWN, 1, &[0]) +} + +/// AS_SEQUENCE of the peer's AS, four octets wide. +fn as_path() -> Vec { + attr(WELL_KNOWN, 2, &[2, 1, 0, 0, 0xfd, 0xe9]) +} + +fn next_hop() -> Vec { + attr(WELL_KNOWN, 3, &[192, 0, 2, 1]) +} + +/// A whole UPDATE message announcing `nlri` with `attributes`, as bytes, so +/// that it can be wrong in ways the encoder would not write. +fn update(attributes: &[Vec], nlri: &[u8]) -> Vec { + let attributes = attributes.concat(); + let mut body = vec![0, 0]; + body.extend_from_slice(&(attributes.len() as u16).to_be_bytes()); + body.extend_from_slice(&attributes); + body.extend_from_slice(nlri); + + let mut wire = vec![0xff; 16]; + wire.extend_from_slice(&((19 + body.len()) as u16).to_be_bytes()); + wire.push(2); + wire.extend_from_slice(&body); + wire +} + +/// 10.1.0.0/16 and 10.2.0.0/16. +const ROUTE_A: [u8; 3] = [16, 10, 1]; +const ROUTE_B: [u8; 3] = [16, 10, 2]; + +fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +async fn established(server: &TestServer, asn: u32) -> Result { + let mut peer = TestPeer::connect(server.addr).await?; + peer.handshake(asn, Ipv4Addr::new(10, 0, 0, 1), 90).await?; + Ok(peer) +} + +/// Waits until the server's Adj-RIB-In holds exactly `prefixes`. +async fn wait_for_routes(server: &TestServer, prefixes: &[&str]) -> Result> { + let mut expected: Vec = prefixes.iter().map(|p| prefix(p)).collect(); + expected.sort_by_key(|p| p.to_string()); + Ok(tokio::time::timeout(TIMEOUT, async { + loop { + let mut routes = server.received_routes(); + routes.sort_by_key(|r| r.prefix.to_string()); + if routes.iter().map(|r| &r.prefix).eq(expected.iter()) { + return routes; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?) +} + +/// The situation RFC 7606 exists for: a route is in the table, and then an +/// UPDATE for it arrives with a broken attribute. The route goes; the +/// session, and the other routes learned over it, stay. +#[tokio::test] +async fn test_a_malformed_attribute_withdraws_the_route_and_keeps_the_session() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established(&server, PEER_ASN).await?; + let handled_before = UPDATE_ERRORS + .with_label_values(&["treat-as-withdraw"]) + .get(); + + let good = [origin(), as_path(), next_hop()]; + peer.send_raw(&update(&good, &[ROUTE_A, ROUTE_B].concat())) + .await?; + wait_for_routes(&server, &["10.1.0.0/16", "10.2.0.0/16"]).await?; + + // ORIGIN with a value that is not defined. + let bad = [attr(WELL_KNOWN, 1, &[7]), as_path(), next_hop()]; + peer.send_raw(&update(&bad, &ROUTE_A)).await?; + wait_for_routes(&server, &["10.2.0.0/16"]).await?; + + // The session goes on: the route can be announced again. + peer.send_raw(&update(&good, &ROUTE_A)).await?; + wait_for_routes(&server, &["10.1.0.0/16", "10.2.0.0/16"]).await?; + assert_eq!(server.registry.list().len(), 1); + assert!( + UPDATE_ERRORS + .with_label_values(&["treat-as-withdraw"]) + .get() + > handled_before + ); + Ok(()) +} + +/// An announcement without a mandatory attribute is not a route we can use. +#[tokio::test] +async fn test_a_route_without_as_path_is_not_accepted() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established(&server, PEER_ASN).await?; + + peer.send_raw(&update(&[origin(), next_hop()], &ROUTE_A)) + .await?; + // A well-formed one after it shows that the first was dealt with. + peer.send_raw(&update(&[origin(), as_path(), next_hop()], &ROUTE_B)) + .await?; + + wait_for_routes(&server, &["10.2.0.0/16"]).await?; + assert_eq!(server.registry.list().len(), 1); + Ok(()) +} + +/// A broken attribute that plays no part in choosing a route is dropped, +/// and the route is kept. +#[tokio::test] +async fn test_a_malformed_aggregator_is_dropped_and_the_route_kept() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established(&server, PEER_ASN).await?; + + let attributes = [ + origin(), + as_path(), + next_hop(), + // AGGREGATOR is eight octets on this session. + attr(OPTIONAL_TRANSITIVE, 7, &[0, 0, 0xfd, 0xe9, 192]), + ]; + peer.send_raw(&update(&attributes, &ROUTE_A)).await?; + + let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?; + assert_eq!(routes[0].attributes.len(), 3); + assert!( + !routes[0] + .attributes + .iter() + .any(|a| matches!(a, PathAttribute::Aggregator(_))) + ); + Ok(()) +} + +/// LOCAL_PREF is a matter between routers of one AS. From an external peer +/// it is dropped; from an internal one it is kept. +#[tokio::test] +async fn test_local_pref_is_kept_only_from_an_internal_peer() -> Result<()> { + let local_pref = attr(WELL_KNOWN, 5, &[0, 0, 0, 200]); + let has_local_pref = |routes: &[ReceivedRoute]| { + routes[0] + .attributes + .contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(200))) + }; + + let server = TestServer::start(LOCAL_ASN).await?; + let mut external = established(&server, PEER_ASN).await?; + let attributes = [origin(), as_path(), next_hop(), local_pref.clone()]; + external.send_raw(&update(&attributes, &ROUTE_A)).await?; + let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?; + assert!(!has_local_pref(&routes)); + + let server = TestServer::start(LOCAL_ASN).await?; + let mut internal = established(&server, LOCAL_ASN).await?; + // An internal peer has not added its AS to the path. + let attributes = [origin(), attr(WELL_KNOWN, 2, &[]), next_hop(), local_pref]; + internal.send_raw(&update(&attributes, &ROUTE_A)).await?; + let routes = wait_for_routes(&server, &["10.1.0.0/16"]).await?; + assert!(has_local_pref(&routes)); + Ok(()) +} + +/// When the prefixes themselves cannot be read there is nothing to +/// withdraw, and the session is reset as before. +#[tokio::test] +async fn test_an_unreadable_prefix_still_resets_the_session() -> Result<()> { + let server = TestServer::start(LOCAL_ASN).await?; + let mut peer = established(&server, PEER_ASN).await?; + + // An IPv4 prefix of length 33. + peer.send_raw(&update( + &[origin(), as_path(), next_hop()], + &[33, 10, 1, 2, 3, 4], + )) + .await?; + + loop { + match tokio::time::timeout(TIMEOUT, peer.recv()).await?? { + Message::KeepAlive => {} + Message::Notification(n) => { + assert_eq!( + n.error, + BgpError::UpdateMsg(UpdateMsgSubcode::InvalidNetworkField) + ); + break; + } + other => bail!("expected NOTIFICATION, got {other:?}"), + } + } + tokio::time::timeout(TIMEOUT, async { + while !server.registry.list().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await?; + Ok(()) +} diff --git a/crates/packet/Cargo.toml b/crates/packet/Cargo.toml index 0faf785..dfa1727 100644 --- a/crates/packet/Cargo.toml +++ b/crates/packet/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bgp-packet" - -description.workspace = true +description = "Parsing and serialisation of BGP messages" +authors.workspace = true edition.workspace = true homepage.workspace = true license.workspace = true @@ -11,10 +11,16 @@ version.workspace = true [dependencies] bitfield.workspace = true bytes.workspace = true -eyre.workspace = true nom.workspace = true serde.workspace = true serde_repr.workspace = true thiserror.workspace = true strum.workspace = true bgp-primitives.workspace = true + +[dev-dependencies] +eyre.workspace = true +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/packet/src/community.rs b/crates/packet/src/community.rs new file mode 100644 index 0000000..aade8ed --- /dev/null +++ b/crates/packet/src/community.rs @@ -0,0 +1,98 @@ +//! Parsing and formatting of community values as text, the way they are +//! written in configuration and shown in status output. +//! +//! 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 thiserror::Error; + +use crate::message::LargeCommunity; + +/// Text that is not a community. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum CommunityError { + #[error("community {0:?} must be ASN:VALUE with 16-bit numbers, or a well-known name")] + Standard(String), + #[error("large community {0:?} must be GLOBAL:LOCAL1:LOCAL2 with 32-bit numbers")] + Large(String), +} + +/// 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), CommunityError> { + let s = s.trim(); + let invalid = || CommunityError::Standard(s.to_string()); + 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(invalid)?; + let parse = |part: &str| part.trim().parse::().map_err(|_| invalid()); + 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 invalid = || CommunityError::Large(s.to_string()); + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 3 { + return Err(invalid()); + } + let parse = |part: &str| part.trim().parse::().map_err(|_| invalid()); + 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/packet/src/errors.rs b/crates/packet/src/errors.rs index aea255e..c2a5354 100644 --- a/crates/packet/src/errors.rs +++ b/crates/packet/src/errors.rs @@ -1,68 +1,79 @@ -use serde::{Deserialize, Serialize}; +use serde::Serialize; use strum::FromRepr; use thiserror::Error; -#[derive(Debug, Error)] -#[error("Unknown BGP error code {code} subcode {subcode}")] -pub struct UnknownErrorCode { - pub code: u8, - pub subcode: u8, +/// A field holds a number that is not one of the values defined for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("invalid {what} value {value}")] +pub struct InvalidValue { + pub what: &'static str, + pub value: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)] -#[repr(u8)] +/// A message that could not be decoded, together with the NOTIFICATION that +/// reports the problem to the peer (RFC 4271 §6). +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("{detail}")] +pub struct DecodeError { + /// The error code and subcode to send. + pub notification: BgpError, + /// The Data field of that NOTIFICATION, where the RFC defines one. + pub data: Vec, + /// What was wrong, for the log. + pub detail: String, +} + +/// The error a NOTIFICATION reports: its code and subcode (RFC 4271 §4.5). +#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize)] pub enum BgpError { - #[error("Message header error")] - MsgHeader(MsgHeaderSubcode) = 1, + #[error("Message header error: {0}")] + MsgHeader(MsgHeaderSubcode), - #[error("Open message error")] - OpenMsg(OpenMsgSubcode) = 2, + #[error("Open message error: {0}")] + OpenMsg(OpenMsgSubcode), - #[error("Update message error")] - UpdateMsg(UpdateMsgSubcode) = 3, + #[error("Update message error: {0}")] + UpdateMsg(UpdateMsgSubcode), #[error("Hold timer expired")] - HoldTimer = 4, + HoldTimer, - #[error("Finite state machine error")] - FsmError(FsmSubcode) = 5, + #[error("Finite state machine error: {0}")] + FsmError(FsmSubcode), - #[error("Cease")] - Cease(CeaseSubcode) = 6, + #[error("Cease: {0}")] + Cease(CeaseSubcode), - #[error("Route refresh message error")] - RouteRefresh(RouteRefreshSubcode) = 7, + #[error("Route refresh message error: {0}")] + RouteRefresh(RouteRefreshSubcode), #[error("Send hold timer expired")] - SendHoldTimer = 8, + SendHoldTimer, + + /// A code or subcode this implementation has no name for. A peer may + /// send any: new ones are assigned, and zero ("unspecific") is always + /// allowed. The session ends the same way, and the numbers are kept + /// for the log. + #[error("error code {code} subcode {subcode}")] + Other { code: u8, subcode: u8 }, } impl BgpError { - pub fn from_code_subcode(code: u8, subcode: u8) -> Result { - let err = || UnknownErrorCode { code, subcode }; - match code { - 1 => Ok(Self::MsgHeader( - MsgHeaderSubcode::try_from(subcode).map_err(|_| err())?, - )), - 2 => Ok(Self::OpenMsg( - OpenMsgSubcode::try_from(subcode).map_err(|_| err())?, - )), - 3 => Ok(Self::UpdateMsg( - UpdateMsgSubcode::try_from(subcode).map_err(|_| err())?, - )), - 4 => Ok(Self::HoldTimer), - 5 => Ok(Self::FsmError( - FsmSubcode::try_from(subcode).map_err(|_| err())?, - )), - 6 => Ok(Self::Cease( - CeaseSubcode::try_from(subcode).map_err(|_| err())?, - )), - 7 => Ok(Self::RouteRefresh( - RouteRefreshSubcode::try_from(subcode).map_err(|_| err())?, - )), - 8 => Ok(Self::SendHoldTimer), - _ => Err(err()), - } + /// The error for a code and subcode off the wire. Every pair has one, + /// so that a peer's NOTIFICATION can always be read. + pub fn from_code_subcode(code: u8, subcode: u8) -> Self { + let known = match code { + 1 => MsgHeaderSubcode::from_repr(subcode).map(Self::MsgHeader), + 2 => OpenMsgSubcode::from_repr(subcode).map(Self::OpenMsg), + 3 => UpdateMsgSubcode::from_repr(subcode).map(Self::UpdateMsg), + 4 => (subcode == 0).then_some(Self::HoldTimer), + 5 => FsmSubcode::from_repr(subcode).map(Self::FsmError), + 6 => CeaseSubcode::from_repr(subcode).map(Self::Cease), + 7 => RouteRefreshSubcode::from_repr(subcode).map(Self::RouteRefresh), + 8 => (subcode == 0).then_some(Self::SendHoldTimer), + _ => None, + }; + known.unwrap_or(Self::Other { code, subcode }) } pub fn code_subcode(&self) -> (u8, u8) { @@ -75,11 +86,12 @@ impl BgpError { Self::Cease(s) => (6, *s as u8), Self::RouteRefresh(s) => (7, *s as u8), Self::SendHoldTimer => (8, 0), + Self::Other { code, subcode } => (*code, *subcode), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum MsgHeaderSubcode { #[error("Connection not synchronized")] @@ -92,16 +104,12 @@ pub enum MsgHeaderSubcode { BadMessageType = 3, } -impl TryFrom for MsgHeaderSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum OpenMsgSubcode { + #[error("Unspecific")] + Unspecific = 0, + #[error("Unsupported BGP version number")] UnsupportedVersion = 1, @@ -124,14 +132,7 @@ pub enum OpenMsgSubcode { RoleMismatch = 8, } -impl TryFrom for OpenMsgSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum UpdateMsgSubcode { #[error("Malformed attribute list")] @@ -165,14 +166,7 @@ pub enum UpdateMsgSubcode { MalformedAsPath = 11, } -impl TryFrom for UpdateMsgSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum FsmSubcode { #[error("Received an unexpected message in OpenSent state")] @@ -185,14 +179,7 @@ pub enum FsmSubcode { UnexpectedEstablished = 3, } -impl TryFrom for FsmSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum CeaseSubcode { #[error("Maximum number of prefixes reached")] @@ -226,23 +213,9 @@ pub enum CeaseSubcode { BfdDown = 10, } -impl TryFrom for CeaseSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, Deserialize, FromRepr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error, Serialize, FromRepr)] #[repr(u8)] pub enum RouteRefreshSubcode { #[error("Invalid message length")] InvalidLength = 1, } - -impl TryFrom for RouteRefreshSubcode { - type Error = (); - fn try_from(v: u8) -> Result { - Self::from_repr(v).ok_or(()) - } -} diff --git a/crates/packet/src/lib.rs b/crates/packet/src/lib.rs index 3fd7cf1..1646d90 100644 --- a/crates/packet/src/lib.rs +++ b/crates/packet/src/lib.rs @@ -1,3 +1,5 @@ +pub mod community; pub mod errors; pub mod message; pub mod open; +pub mod update_decode; diff --git a/crates/packet/src/message.rs b/crates/packet/src/message.rs index 58daa8d..d19e364 100644 --- a/crates/packet/src/message.rs +++ b/crates/packet/src/message.rs @@ -1,30 +1,29 @@ use std::net::{Ipv4Addr, Ipv6Addr}; -use std::ops::Deref; use bitfield::bitfield; use bytes::BufMut; use bytes::BytesMut; -use eyre::Context; -use eyre::Result; -use eyre::bail; -use eyre::eyre; use nom::Err::Failure; use nom::IResult; use nom::Parser; use nom::number::complete::be_u8; use nom::number::complete::be_u16; use nom::number::complete::be_u32; -use serde::{Deserialize, Serialize}; -use serde_repr::{Deserialize_repr, Serialize_repr}; -use strum::EnumDiscriminants; +use serde::Serialize; +use serde_repr::Serialize_repr; -use bgp_primitives::constants::AddressFamilyId; -use bgp_primitives::constants::SubsequentAfi; -use bgp_primitives::ip_prefix::IpPrefix; +use crate::update_decode::UpdateError; +use bgp_primitives::constants::{Afi, AfiSafi, IpVersion, Safi}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; use bgp_primitives::parser::BgpParserError; +use bgp_primitives::parser::EncodeError; use bgp_primitives::parser::ParserContext; +use bgp_primitives::parser::ProtocolErrorValues; use crate::errors::BgpError; +use crate::errors::DecodeError; +use crate::errors::MsgHeaderSubcode; +use crate::errors::OpenMsgSubcode; use crate::open::OpenMessage; /// A sentinel AS number used to denote that the actual AS number is provided as @@ -42,282 +41,391 @@ pub const BGP_MAX_MSG_LEN: u16 = 4096; const BGP_MARKER: [u8; 16] = [0xFF; 16]; +/// BGP message type codes (RFC 4271 §4.1, RFC 2918). +mod msg_type { + pub const OPEN: u8 = 1; + pub const UPDATE: u8 = 2; + pub const NOTIFICATION: u8 = 3; + pub const KEEPALIVE: u8 = 4; + pub const ROUTE_REFRESH: u8 = 5; +} + /// Message represents the top-level messages in the BGP protocol. -#[derive(Debug, Serialize, Deserialize, EnumDiscriminants)] -#[repr(u8)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum Message { - Open(OpenMessage) = 1, - Update(UpdateMessage) = 2, - Notification(NotificationMessage) = 3, - KeepAlive = 4, + Open(OpenMessage), + Update(UpdateMessage), + Notification(NotificationMessage), + KeepAlive, /// BGP Route Refresh message (RFC 2918). - RouteRefresh = 5, + RouteRefresh(RouteRefreshMessage), +} + +fn header_error(subcode: MsgHeaderSubcode, data: Vec, detail: String) -> DecodeError { + DecodeError { + notification: BgpError::MsgHeader(subcode), + data, + detail, + } } impl Message { - /// Parses a complete BGP message including the 19-byte header from `buf`. - /// Returns the unparsed remainder and the decoded `Message`. - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, marker) = nom::bytes::take(16usize).parse(buf)?; - if marker != BGP_MARKER { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Invalid BGP marker: expected 16 x 0xFF" - )))); - } - - let (buf, length) = be_u16(buf)?; - if length < BGP_HEADER_LEN { - return Err(Failure(BgpParserError::Eyre(eyre!( - "BGP message length {length} is below minimum {BGP_HEADER_LEN}" - )))); - } - if length > BGP_MAX_MSG_LEN { - return Err(Failure(BgpParserError::Eyre(eyre!( - "BGP message length {length} exceeds maximum {BGP_MAX_MSG_LEN}" - )))); - } - - let (buf, msg_type) = be_u8(buf)?; - let body_len = length - BGP_HEADER_LEN; - let (buf, body) = nom::bytes::take(body_len as usize).parse(buf)?; - - let msg = match msg_type { - 1 => { - let (_, open) = OpenMessage::from_wire(ctx, body)?; - Self::Open(open) - } - 2 => { - let (_, update) = UpdateMessage::from_wire(ctx, body)?; - Self::Update(update) - } - 3 => { - let (_, notif) = NotificationMessage::from_wire(ctx, body)?; - Self::Notification(notif) - } - 4 => { - if !body.is_empty() { - return Err(Failure(BgpParserError::Eyre(eyre!( - "KEEPALIVE body must be empty, got {} bytes", - body.len() - )))); - } - Self::KeepAlive - } - 5 => Self::RouteRefresh, - t => { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Unknown BGP message type {t}" - )))); - } - }; - - Ok((buf, msg)) + /// Decodes one complete BGP message, header included, from the start of + /// `buf`. Bytes after the message are ignored. + /// + /// A failure says which NOTIFICATION reports it, following RFC 4271 §6.1 + /// for the header and §6.2 and §6.3 for OPEN and UPDATE bodies. + /// + /// An UPDATE comes back as it is to be acted on: errors that RFC 7606 + /// handles without resetting the session have been handled. Use + /// [`Message::decode_with_report`] to learn of them. + pub fn decode(ctx: &ParserContext, buf: &[u8]) -> Result { + Self::decode_with_report(ctx, buf).map(|decoded| decoded.message) } - /// Serializes the message (header + body) into `out`. - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - // Serialize the body first so we can compute the total length. - let mut body = BytesMut::new(); - match self { - Self::Open(m) => m.to_wire(ctx, &mut body)?, - Self::Update(m) => m.to_wire(ctx, &mut body)?, - Self::Notification(m) => m.to_wire(ctx, &mut body)?, - Self::KeepAlive | Self::RouteRefresh => {} + /// As [`Message::decode`], and also returns what else decoding an + /// UPDATE found out: the errors in it that were handled without + /// resetting the session, and its attributes as they were on the wire. + pub fn decode_with_report(ctx: &ParserContext, buf: &[u8]) -> Result { + let header_len = usize::from(BGP_HEADER_LEN); + if buf.len() < header_len { + return Err(header_error( + MsgHeaderSubcode::BadMessageLength, + vec![], + format!("{} bytes is shorter than a BGP header", buf.len()), + )); } + if buf[..16] != BGP_MARKER { + return Err(header_error( + MsgHeaderSubcode::ConnNotSynchronized, + vec![], + "invalid BGP marker: expected 16 x 0xFF".into(), + )); + } + let length = u16::from_be_bytes([buf[16], buf[17]]); + let type_code = buf[18]; + if !(BGP_HEADER_LEN..=BGP_MAX_MSG_LEN).contains(&length) || usize::from(length) > buf.len() + { + return Err(header_error( + MsgHeaderSubcode::BadMessageLength, + length.to_be_bytes().to_vec(), + format!( + "BGP message length {length} is not between {BGP_HEADER_LEN} and \ + {BGP_MAX_MSG_LEN}, or exceeds the {} bytes received", + buf.len() + ), + )); + } + let body = &buf[header_len..usize::from(length)]; + let bad_length = |what: &str| { + header_error( + MsgHeaderSubcode::BadMessageLength, + length.to_be_bytes().to_vec(), + format!("{what} with a length of {length}"), + ) + }; - let total_len = BGP_HEADER_LEN - .checked_add(body.len() as u16) - .filter(|&l| l <= BGP_MAX_MSG_LEN) - .ok_or_else(|| eyre!("BGP message body too large to serialize"))?; + let message = match type_code { + msg_type::OPEN => Self::Open(decode_open(body)?), + msg_type::UPDATE => { + let decoded = UpdateMessage::decode(ctx, body)?; + return Ok(Decoded { + message: Self::Update(decoded.update), + update_errors: decoded.errors, + attributes_wire: decoded.attributes_wire, + }); + } + msg_type::NOTIFICATION => match body { + [code, subcode, data @ ..] => Self::Notification(NotificationMessage { + error: BgpError::from_code_subcode(*code, *subcode), + data: data.to_vec(), + }), + _ => return Err(bad_length("NOTIFICATION")), + }, + msg_type::KEEPALIVE if body.is_empty() => Self::KeepAlive, + msg_type::KEEPALIVE => return Err(bad_length("KEEPALIVE with a body")), + msg_type::ROUTE_REFRESH => match body { + [afi_hi, afi_lo, subtype, safi] => Self::RouteRefresh(RouteRefreshMessage { + afi: Afi(u16::from_be_bytes([*afi_hi, *afi_lo])), + subtype: *subtype, + safi: Safi(*safi), + }), + _ => return Err(bad_length("ROUTE-REFRESH")), + }, + other => { + return Err(header_error( + MsgHeaderSubcode::BadMessageType, + vec![other], + format!("unknown BGP message type {other}"), + )); + } + }; + Ok(Decoded { + message, + update_errors: Vec::new(), + attributes_wire: None, + }) + } + /// Writes the message, header and body, to the end of `out`. If it + /// cannot be encoded, `out` is left as it was. + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + let start = out.len(); out.put_slice(&BGP_MARKER); - out.put_u16(total_len); + // The length, filled in once the body is written. + out.put_u16(0); out.put_u8(self.type_code()); - out.put(body); - Ok(()) + let written = self.body_to_wire(ctx, out).and_then(|()| { + let length = out.len() - start; + u16::try_from(length) + .ok() + .filter(|length| *length <= BGP_MAX_MSG_LEN) + .ok_or(EncodeError::TooLong { + what: "BGP message", + len: length, + max: BGP_MAX_MSG_LEN.into(), + }) + }); + match written { + Ok(length) => { + out[start + 16..start + 18].copy_from_slice(&length.to_be_bytes()); + Ok(()) + } + Err(e) => { + out.truncate(start); + Err(e) + } + } } - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - let body_len: u16 = match self { - Self::Open(m) => m.wire_len(ctx)?, - Self::Update(m) => m.wire_len(ctx)?, - Self::Notification(m) => m.wire_len(), - Self::KeepAlive | Self::RouteRefresh => 0, - }; - Ok(BGP_HEADER_LEN + body_len) + fn body_to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + match self { + Self::Open(m) => m.to_wire(out), + Self::Update(m) => m.to_wire(ctx, out), + Self::Notification(m) => { + m.to_wire(out); + Ok(()) + } + Self::RouteRefresh(m) => { + out.put_u16(m.afi.0); + out.put_u8(m.subtype); + out.put_u8(m.safi.0); + Ok(()) + } + Self::KeepAlive => Ok(()), + } } fn type_code(&self) -> u8 { match self { - Self::Open(_) => 1, - Self::Update(_) => 2, - Self::Notification(_) => 3, - Self::KeepAlive => 4, - Self::RouteRefresh => 5, + Self::Open(_) => msg_type::OPEN, + Self::Update(_) => msg_type::UPDATE, + Self::Notification(_) => msg_type::NOTIFICATION, + Self::KeepAlive => msg_type::KEEPALIVE, + Self::RouteRefresh(_) => msg_type::ROUTE_REFRESH, } } } +/// Decodes the body of an OPEN, with the NOTIFICATION that reports a +/// failure (RFC 4271 §6.2). +fn decode_open(body: &[u8]) -> Result { + let refused = |subcode, data: Vec, detail: String| DecodeError { + notification: BgpError::OpenMsg(subcode), + data, + detail: format!("malformed OPEN: {detail}"), + }; + match OpenMessage::from_wire(body) { + Ok(([], open)) => Ok(open), + Ok((rest, _)) => Err(refused( + OpenMsgSubcode::Unspecific, + vec![], + format!("{} octets after the optional parameters", rest.len()), + )), + Err(e) => { + let (subcode, data) = match &e { + nom::Err::Error(BgpParserError::ProtocolError(value)) + | nom::Err::Failure(BgpParserError::ProtocolError(value)) => match value { + // The Data field is the largest version we do support. + ProtocolErrorValues::UnsupportedVersion(_) => ( + OpenMsgSubcode::UnsupportedVersion, + u16::from(BGP4_VERSION).to_be_bytes().to_vec(), + ), + ProtocolErrorValues::UnknownOpenOption(_) => { + (OpenMsgSubcode::UnsupportedOptionalParam, vec![]) + } + }, + _ => (OpenMsgSubcode::Unspecific, vec![]), + }; + Err(refused(subcode, data, describe(&e))) + } + } +} + +/// A message, and what else decoding it found out. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decoded { + pub message: Message, + /// The errors in an UPDATE that were handled without resetting the + /// session (RFC 7606). Empty for a well-formed UPDATE and for every + /// other message. + pub update_errors: Vec, + /// See [`crate::update_decode::DecodedUpdate::attributes_wire`]. + pub attributes_wire: Option>, +} + +/// A request to send again every route of one address family (RFC 2918). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct RouteRefreshMessage { + pub afi: Afi, + /// Zero for a plain request. RFC 7313 uses the octet, reserved in + /// RFC 2918, to mark the beginning and end of a refresh. + pub subtype: u8, + pub safi: Safi, +} + +impl RouteRefreshMessage { + /// A plain request for the routes of `family`. + pub fn request(family: AfiSafi) -> Self { + Self { + afi: family.afi, + subtype: 0, + safi: family.safi, + } + } + + pub fn family(&self) -> AfiSafi { + AfiSafi::new(self.afi, self.safi) + } +} + /// Represents a BGP Update message. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct UpdateMessage { - pub withdrawn_routes: Vec, + pub withdrawn_routes: Vec, pub path_attributes: Vec, - pub announced_routes: Vec, + pub announced_routes: Vec, } impl UpdateMessage { - /// Parses a BGP UPDATE message body per RFC 4271 §4.3. The buffer passed in - /// must contain exactly the UPDATE payload (i.e. without the 19 byte BGP - /// message header). The NLRI section has no explicit length and is inferred - /// as the remainder of the buffer, so passing extra trailing bytes will - /// cause them to be (mis-)parsed as NLRI. - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - // Withdrawn routes. Per RFC 4271 these are always IPv4 prefixes; other - // address families are carried in the MP_UNREACH_NLRI path attribute. - let (buf, withdrawn_len) = be_u16(buf)?; - let (buf, withdrawn_buf) = nom::bytes::take(withdrawn_len as usize).parse(buf)?; - let (withdrawn_rem, withdrawn_routes) = - nom::multi::many0(|buf| parse_prefix(AddressFamilyId::Ipv4, buf)) - .parse(withdrawn_buf)?; - if !withdrawn_rem.is_empty() { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Leftover bytes {:x?} after parsing withdrawn routes", - withdrawn_rem - )))); - } - - // Path attributes. - let (buf, total_pa_len) = be_u16(buf)?; - let (buf, pa_buf) = nom::bytes::take(total_pa_len as usize).parse(buf)?; - let (pa_rem, path_attributes) = - nom::multi::many0(|buf| PathAttribute::from_wire(ctx, buf)).parse(pa_buf)?; - if !pa_rem.is_empty() { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Leftover bytes {:x?} after parsing path attributes", - pa_rem - )))); - } - - // NLRI. As with withdrawn routes, these are IPv4 prefixes per RFC 4271. - // They occupy the remainder of the UPDATE body; if the caller has - // delimited `buf` correctly then `many0` will consume it entirely. - let (buf, announced_routes) = - nom::multi::many0(|buf| parse_prefix(AddressFamilyId::Ipv4, buf)).parse(buf)?; - if !buf.is_empty() { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Leftover bytes {:x?} after parsing NLRI", - buf - )))); - } - - Ok(( - buf, - Self { - withdrawn_routes, - path_attributes, - announced_routes, - }, - )) - } - - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - // Withdrawn routes. - let withdrawn_len = prefix_list_wire_len(&self.withdrawn_routes) - .wrap_err("Withdrawn routes length exceeds u16::MAX")?; - out.put_u16(withdrawn_len); - for prefix in &self.withdrawn_routes { - out.put_u8(prefix.length); - out.put(prefix.prefix.as_slice()); - } - - // Path attributes. - let pa_total: u32 = self - .path_attributes - .iter() - .map(|p| p.wire_len(ctx).map(|v| v as u32)) - .sum::>()?; - let pa_total = u16::try_from(pa_total) - .map_err(|_| eyre!("Total path attribute length exceeds u16::MAX"))?; - out.put_u16(pa_total); - for pa in &self.path_attributes { - pa.to_wire(ctx, out)?; - } - - // NLRI (consumes the remainder of the message). - for prefix in &self.announced_routes { - out.put_u8(prefix.length); - out.put(prefix.prefix.as_slice()); + /// Writes the body of the UPDATE (what follows the 19-octet header). + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + // The prefixes in the body are IPv4 unicast (RFC 4271 §4.3). + let with_path_id = ctx.has_add_path(AfiSafi::IPV4_UNICAST); + + let withdrawn = reserve_length(out); + write_nlri(&self.withdrawn_routes, with_path_id, out); + fill_length(out, withdrawn, "withdrawn routes")?; + + let attributes = reserve_length(out); + for attribute in &self.path_attributes { + attribute.to_wire(ctx, out)?; } + fill_length(out, attributes, "path attributes")?; + // The NLRI take up the rest of the message. + write_nlri(&self.announced_routes, with_path_id, out); Ok(()) } - - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - // 2 bytes for the withdrawn length field + 2 bytes for the total path - // attribute length field. - let mut total: u32 = 4; - for prefix in &self.withdrawn_routes { - total += 1 + prefix.prefix.len() as u32; - } - for pa in &self.path_attributes { - total += pa.wire_len(ctx)? as u32; - } - for prefix in &self.announced_routes { - total += 1 + prefix.prefix.len() as u32; - } - u16::try_from(total).map_err(|_| eyre!("UPDATE message length exceeds u16::MAX")) - } } -/// Computes the total wire length of a list of IPv4/IPv6 NLRI prefixes as they -/// appear in the withdrawn or NLRI sections of an UPDATE message (each prefix -/// is encoded as a 1-byte length followed by `ceil(length/8)` prefix bytes). -fn prefix_list_wire_len(prefixes: &[IpPrefix]) -> Result { - let total: u32 = prefixes.iter().map(|p| 1 + p.prefix.len() as u32).sum(); - u16::try_from(total).map_err(|_| eyre!("Prefix list length exceeds u16::MAX")) +/// Leaves room at the end of `out` for a two-octet length that is known +/// only once what it counts has been written, and returns where. +fn reserve_length(out: &mut BytesMut) -> usize { + let at = out.len(); + out.put_u16(0); + at +} + +/// Fills in the length reserved at `at`: everything written since. +fn fill_length(out: &mut BytesMut, at: usize, what: &'static str) -> Result<(), EncodeError> { + let length = out.len() - at - 2; + let field = u16::try_from(length).map_err(|_| EncodeError::TooLong { + what, + len: length, + max: u16::MAX.into(), + })?; + out[at..at + 2].copy_from_slice(&field.to_be_bytes()); + Ok(()) +} + +/// Writes prefixes as an UPDATE names them: for each, its path identifier +/// if the session uses them for the family (RFC 7911 §3), its length in +/// bits, and the bytes that length covers. +fn write_nlri(nlri: &[Nlri], with_path_id: bool, out: &mut BytesMut) { + for n in nlri { + if with_path_id { + out.put_u32(n.path_id); + } + out.put_u8(n.prefix.length()); + out.put(n.prefix.bytes()); + } +} + +/// A parser failure as text for a log line. +pub(crate) fn describe(e: &nom::Err>) -> String { + match e { + nom::Err::Incomplete(_) => "truncated".into(), + nom::Err::Error(inner) | nom::Err::Failure(inner) => match inner { + BgpParserError::Malformed(text) => text.clone(), + BgpParserError::ProtocolError(value) => format!("{value:?}"), + BgpParserError::Nom(rest, kind) => { + format!("unexpected input ({kind:?}) with {} bytes left", rest.len()) + } + }, + } } bitfield! { - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct PathAttributeFlags(u8); impl new; u8; - optional, set_optional: 7; - transitive, set_transitive: 6; - partial, set_partial: 5; - extended_length, set_extended_length: 4; + pub optional, set_optional: 7; + pub transitive, set_transitive: 6; + pub partial, set_partial: 5; + pub extended_length, set_extended_length: 4; } -impl Deref for PathAttributeFlags { - type Target = u8; - - fn deref(&self) -> &Self::Target { - &self.0 - } +/// Path attribute type codes. +pub mod attr_type { + pub const ORIGIN: u8 = 1; + pub const AS_PATH: u8 = 2; + pub const NEXT_HOP: u8 = 3; + pub const MULTI_EXIT_DISC: u8 = 4; + pub const LOCAL_PREF: u8 = 5; + pub const ATOMIC_AGGREGATE: u8 = 6; + pub const AGGREGATOR: u8 = 7; + pub const COMMUNITIES: u8 = 8; + pub const ORIGINATOR_ID: u8 = 9; + pub const CLUSTER_LIST: u8 = 10; + pub const MP_REACH_NLRI: u8 = 14; + pub const MP_UNREACH_NLRI: u8 = 15; + pub const EXTENDED_COMMUNITIES: u8 = 16; + pub const AS4_PATH: u8 = 17; + pub const AS4_AGGREGATOR: u8 = 18; + pub const LARGE_COMMUNITIES: u8 = 32; } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, EnumDiscriminants)] -#[repr(u8)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum PathAttribute { - Origin(OriginPathAttribute) = 1, - AsPath(AsPathAttribute) = 2, - NextHop(NextHopPathAttribute) = 3, - MultiExitDisc(MultiExitDiscPathAttribute) = 4, - LocalPref(LocalPrefPathAttribute) = 5, - AtomicAggregate(AtomicAggregatePathAttribute) = 6, - Aggregator(AggregatorPathAttribute) = 7, - Communitites(CommunitiesPathAttribute) = 8, - MpReachNlri(MpReachNlriPathAttribute) = 14, - MpUnreachNlri(MpUnreachNlriPathAttribute) = 15, - ExtendedCommunities(ExtendedCommunitiesPathAttribute) = 16, - LargeCommunities(LargeCommunitiesPathAttribute) = 32, + Origin(OriginPathAttribute), + AsPath(AsPathAttribute), + NextHop(NextHopPathAttribute), + MultiExitDisc(MultiExitDiscPathAttribute), + LocalPref(LocalPrefPathAttribute), + AtomicAggregate(AtomicAggregatePathAttribute), + Aggregator(AggregatorPathAttribute), + Communities(CommunitiesPathAttribute), + MpReachNlri(MpReachNlriPathAttribute), + MpUnreachNlri(MpUnreachNlriPathAttribute), + ExtendedCommunities(ExtendedCommunitiesPathAttribute), + /// The AS path with four-octet AS numbers, for a route that crosses + /// speakers that only handle two-octet ones (RFC 6793 §3). + As4Path(AsPathAttribute), + LargeCommunities(LargeCommunitiesPathAttribute), + /// An attribute this implementation does not interpret, kept as it + /// came so that it can be passed on. UnknownPathAttribute { flags: PathAttributeFlags, type_code: u8, @@ -325,286 +433,258 @@ pub enum PathAttribute { }, } -impl From for PathAttributeDiscriminants { - fn from(value: u8) -> Self { - match value { - v if v == Self::Origin as u8 => Self::Origin, - v if v == Self::AsPath as u8 => Self::AsPath, - v if v == Self::NextHop as u8 => Self::NextHop, - v if v == Self::MultiExitDisc as u8 => Self::MultiExitDisc, - v if v == Self::LocalPref as u8 => Self::LocalPref, - v if v == Self::AtomicAggregate as u8 => Self::AtomicAggregate, - v if v == Self::Aggregator as u8 => Self::Aggregator, - v if v == Self::Communitites as u8 => Self::Communitites, - v if v == Self::MpReachNlri as u8 => Self::MpReachNlri, - v if v == Self::MpUnreachNlri as u8 => Self::MpUnreachNlri, - v if v == Self::ExtendedCommunities as u8 => Self::ExtendedCommunities, - v if v == Self::LargeCommunities as u8 => Self::LargeCommunities, - _ => Self::UnknownPathAttribute, +/// The Optional and Transitive flags that the attribute with this type code +/// is defined to have, for the attributes whose definition we know. A +/// received attribute with other values is in error (RFC 7606 §3.c). +pub(crate) fn defined_flags(type_code: u8) -> Option<(bool, bool)> { + use attr_type::*; + const WELL_KNOWN: (bool, bool) = (false, true); + const OPTIONAL_TRANSITIVE: (bool, bool) = (true, true); + const OPTIONAL_NON_TRANSITIVE: (bool, bool) = (true, false); + Some(match type_code { + ORIGIN | AS_PATH | NEXT_HOP | LOCAL_PREF | ATOMIC_AGGREGATE => WELL_KNOWN, + MULTI_EXIT_DISC | ORIGINATOR_ID | CLUSTER_LIST | MP_REACH_NLRI | MP_UNREACH_NLRI => { + OPTIONAL_NON_TRANSITIVE + } + AGGREGATOR | COMMUNITIES | EXTENDED_COMMUNITIES | AS4_PATH | AS4_AGGREGATOR + | LARGE_COMMUNITIES => OPTIONAL_TRANSITIVE, + _ => return None, + }) +} + +/// A parsed value, provided the parser used all of it. +fn whole((rest, parsed): (&[u8], T)) -> Result>> { + if rest.is_empty() { + Ok(parsed) + } else { + Err(Failure(BgpParserError::Malformed(format!( + "{} octets left over", + rest.len() + )))) + } +} + +/// One attribute as it lies on the wire, split out but not interpreted. +#[derive(Debug, Clone)] +pub struct RawAttribute<'a> { + pub flags: PathAttributeFlags, + pub type_code: u8, + pub value: &'a [u8], + /// The whole attribute, header and value. + pub wire: &'a [u8], +} + +/// Why the bytes at the front of a buffer are not an attribute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotAnAttribute { + /// Too few octets for flags, type and length. + NoHeader, + /// The length says more than there is. + Truncated { + type_code: u8, + length: usize, + available: usize, + }, +} + +impl std::fmt::Display for NotAnAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoHeader => f.write_str("too few octets for an attribute"), + Self::Truncated { + type_code, + length, + available, + } => write!( + f, + "attribute {type_code} has length {length} but only {available} octets remain" + ), } } } +/// Splits the attribute at the front of `buf` from what follows it. +pub fn split_attribute(buf: &[u8]) -> Result<(RawAttribute<'_>, &[u8]), NotAnAttribute> { + let [flags, type_code, rest @ ..] = buf else { + return Err(NotAnAttribute::NoHeader); + }; + let flags = PathAttributeFlags(*flags); + let (length, rest) = match (flags.extended_length(), rest) { + (true, [hi, lo, rest @ ..]) => (usize::from(u16::from_be_bytes([*hi, *lo])), rest), + (false, [length, rest @ ..]) => (usize::from(*length), rest), + _ => return Err(NotAnAttribute::NoHeader), + }; + let Some((value, rest)) = rest.split_at_checked(length) else { + return Err(NotAnAttribute::Truncated { + type_code: *type_code, + length, + available: rest.len(), + }); + }; + let raw = RawAttribute { + flags, + type_code: *type_code, + value, + wire: &buf[..buf.len() - rest.len()], + }; + Ok((raw, rest)) +} + impl PathAttribute { - fn discriminant(&self) -> u8 { + pub fn type_code(&self) -> u8 { + use attr_type::*; match self { - PathAttribute::Origin(_) => PathAttributeDiscriminants::Origin as u8, - PathAttribute::AsPath(_) => PathAttributeDiscriminants::AsPath as u8, - PathAttribute::NextHop(_) => PathAttributeDiscriminants::NextHop as u8, - PathAttribute::MultiExitDisc(_) => PathAttributeDiscriminants::MultiExitDisc as u8, - PathAttribute::LocalPref(_) => PathAttributeDiscriminants::LocalPref as u8, - PathAttribute::AtomicAggregate(_) => PathAttributeDiscriminants::AtomicAggregate as u8, - PathAttribute::Aggregator(_) => PathAttributeDiscriminants::Aggregator as u8, - PathAttribute::Communitites(_) => PathAttributeDiscriminants::Communitites as u8, - PathAttribute::MpReachNlri(_) => PathAttributeDiscriminants::MpReachNlri as u8, - PathAttribute::MpUnreachNlri(_) => PathAttributeDiscriminants::MpUnreachNlri as u8, - PathAttribute::ExtendedCommunities(_) => { - PathAttributeDiscriminants::ExtendedCommunities as u8 + Self::Origin(_) => ORIGIN, + Self::AsPath(_) => AS_PATH, + Self::NextHop(_) => NEXT_HOP, + Self::MultiExitDisc(_) => MULTI_EXIT_DISC, + Self::LocalPref(_) => LOCAL_PREF, + Self::AtomicAggregate(_) => ATOMIC_AGGREGATE, + Self::Aggregator(_) => AGGREGATOR, + Self::Communities(_) => COMMUNITIES, + Self::MpReachNlri(_) => MP_REACH_NLRI, + Self::MpUnreachNlri(_) => MP_UNREACH_NLRI, + Self::ExtendedCommunities(_) => EXTENDED_COMMUNITIES, + Self::As4Path(_) => AS4_PATH, + Self::LargeCommunities(_) => LARGE_COMMUNITIES, + Self::UnknownPathAttribute { type_code, .. } => *type_code, + } + } + + /// The flags to send the attribute with, but for Extended Length, which + /// depends on how long the value turns out to be. + fn flags(&self) -> PathAttributeFlags { + match self { + // As they came: the Partial bit has to survive (RFC 4271 §5). + Self::UnknownPathAttribute { flags, .. } => flags.clone(), + known => { + let (optional, transitive) = defined_flags(known.type_code()) + .expect("every attribute with a variant has defined flags"); + PathAttributeFlags::new(optional, transitive, false, false) } - PathAttribute::LargeCommunities(_) => { - PathAttributeDiscriminants::LargeCommunities as u8 - } - PathAttribute::UnknownPathAttribute { type_code, .. } => *type_code, } } - fn optional(&self) -> bool { - match self { - PathAttribute::Origin(_) => false, - PathAttribute::AsPath(_) => false, - PathAttribute::NextHop(_) => false, - PathAttribute::MultiExitDisc(_) => true, - PathAttribute::LocalPref(_) => false, - PathAttribute::AtomicAggregate(_) => true, - PathAttribute::Aggregator(_) => true, - PathAttribute::Communitites(_) => true, - PathAttribute::MpReachNlri(_) => true, - PathAttribute::MpUnreachNlri(_) => true, - PathAttribute::ExtendedCommunities(_) => true, - PathAttribute::LargeCommunities(_) => true, - PathAttribute::UnknownPathAttribute { flags, .. } => flags.optional(), - } - } - - fn transitive(&self) -> bool { - match self { - PathAttribute::Origin(_) => true, - PathAttribute::AsPath(_) => true, - PathAttribute::NextHop(_) => true, - PathAttribute::MultiExitDisc(_) => false, - PathAttribute::LocalPref(_) => true, - PathAttribute::AtomicAggregate(_) => true, - PathAttribute::Aggregator(_) => false, - PathAttribute::Communitites(_) => true, - PathAttribute::MpReachNlri(_) => false, - PathAttribute::MpUnreachNlri(_) => false, - PathAttribute::ExtendedCommunities(_) => true, - PathAttribute::LargeCommunities(_) => true, - PathAttribute::UnknownPathAttribute { flags, .. } => flags.transitive(), - } - } - - /// The from_wire parser for `PathAttribute` consumes type and length which it uses to - /// determine how many bytes to take and pass down to the corresponding sub-parser. + /// Parses one attribute, header and value, from the front of `buf`. + /// The flags are not checked against the attribute's definition; + /// [`UpdateMessage::decode`] does that for attributes off a session. pub fn from_wire<'a>( ctx: &ParserContext, buf: &'a [u8], ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, attr_flags) = be_u8(buf).map(|(buf, b)| (buf, PathAttributeFlags(b)))?; - let (buf, type_code) = be_u8(buf)?; - - let (buf, length): (_, u16) = if attr_flags.extended_length() { - be_u16(buf)? - } else { - be_u8(buf).map(|(buf, b)| (buf, b as u16))? - }; - - let discriminant = PathAttributeDiscriminants::from(type_code); - if matches!( - discriminant, - PathAttributeDiscriminants::UnknownPathAttribute - ) { - let (buf, pa_buf) = nom::bytes::take(length).parse(buf)?; - return Ok(( - buf, - PathAttribute::UnknownPathAttribute { - flags: attr_flags, - type_code, - payload: pa_buf.to_vec(), - }, - )); - } - Self::parse_known_path_attribute(ctx, buf, discriminant, length) + let (raw, rest) = + split_attribute(buf).map_err(|e| Failure(BgpParserError::Malformed(e.to_string())))?; + let attribute = Self::parse_value(ctx, raw.flags, raw.type_code, raw.value)?; + Ok((rest, attribute)) } - fn parse_known_path_attribute<'a>( + /// Makes the attribute with this type code from its value. `value` is + /// exactly the value, and all of it has to be used. A type code without + /// a variant of its own is kept as bytes. + pub(crate) fn parse_value<'a>( ctx: &ParserContext, - buf: &'a [u8], - discriminant: PathAttributeDiscriminants, - length: u16, - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, pa_buf) = nom::bytes::take(length).parse(buf)?; - - let attr: PathAttribute = match discriminant { - PathAttributeDiscriminants::Origin => { - PathAttribute::Origin(OriginPathAttribute::from_wire(ctx, pa_buf)?.1) + flags: PathAttributeFlags, + type_code: u8, + value: &'a [u8], + ) -> Result>> { + use attr_type::*; + Ok(match type_code { + ORIGIN => Self::Origin(whole(OriginPathAttribute::from_wire(value)?)?), + AS_PATH => Self::AsPath(whole(AsPathAttribute::from_wire(ctx, value)?)?), + NEXT_HOP => Self::NextHop(whole(NextHopPathAttribute::from_wire(value)?)?), + MULTI_EXIT_DISC => { + Self::MultiExitDisc(whole(MultiExitDiscPathAttribute::from_wire(value)?)?) } - PathAttributeDiscriminants::AsPath => { - PathAttribute::AsPath(AsPathAttribute::from_wire(ctx, pa_buf)?.1) + LOCAL_PREF => Self::LocalPref(whole(LocalPrefPathAttribute::from_wire(value)?)?), + ATOMIC_AGGREGATE => { + Self::AtomicAggregate(whole((value, AtomicAggregatePathAttribute {}))?) } - PathAttributeDiscriminants::NextHop => { - PathAttribute::NextHop(NextHopPathAttribute::from_wire(ctx, pa_buf)?.1) + AGGREGATOR => Self::Aggregator(whole(AggregatorPathAttribute::from_wire(ctx, value)?)?), + COMMUNITIES => Self::Communities(whole(CommunitiesPathAttribute::from_wire(value)?)?), + MP_REACH_NLRI => { + Self::MpReachNlri(whole(MpReachNlriPathAttribute::from_wire(ctx, value)?)?) } - PathAttributeDiscriminants::MultiExitDisc => { - PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute::from_wire(ctx, pa_buf)?.1) + MP_UNREACH_NLRI => { + Self::MpUnreachNlri(whole(MpUnreachNlriPathAttribute::from_wire(ctx, value)?)?) } - PathAttributeDiscriminants::LocalPref => { - PathAttribute::LocalPref(LocalPrefPathAttribute::from_wire(ctx, pa_buf)?.1) + EXTENDED_COMMUNITIES => Self::ExtendedCommunities(whole( + ExtendedCommunitiesPathAttribute::from_wire(value)?, + )?), + // Always four octets wide, whatever the session's AS_PATH is. + AS4_PATH => Self::As4Path(whole(AsPathAttribute::from_wire_with_width(true, value)?)?), + LARGE_COMMUNITIES => { + Self::LargeCommunities(whole(LargeCommunitiesPathAttribute::from_wire(value)?)?) } - PathAttributeDiscriminants::AtomicAggregate => PathAttribute::AtomicAggregate( - AtomicAggregatePathAttribute::from_wire(ctx, pa_buf)?.1, - ), - PathAttributeDiscriminants::Aggregator => { - PathAttribute::Aggregator(AggregatorPathAttribute::from_wire(ctx, pa_buf)?.1) - } - PathAttributeDiscriminants::Communitites => { - PathAttribute::Communitites(CommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1) - } - PathAttributeDiscriminants::MpReachNlri => { - PathAttribute::MpReachNlri(MpReachNlriPathAttribute::from_wire(ctx, pa_buf)?.1) - } - PathAttributeDiscriminants::MpUnreachNlri => { - PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute::from_wire(ctx, pa_buf)?.1) - } - PathAttributeDiscriminants::ExtendedCommunities => PathAttribute::ExtendedCommunities( - ExtendedCommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1, - ), - PathAttributeDiscriminants::LargeCommunities => PathAttribute::LargeCommunities( - LargeCommunitiesPathAttribute::from_wire(ctx, pa_buf)?.1, - ), - PathAttributeDiscriminants::UnknownPathAttribute => unreachable!( - "parse_known_path_attribute must never be called with an unknown attribute" - ), - }; - Ok((buf, attr)) - } - - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - macro_rules! write_path_attribute { - ($out: expr, $attribute: expr) => { - let wire_len = $attribute.wire_len(ctx)?; - let attr_flags = PathAttributeFlags::new( - self.optional(), - self.transitive(), - /*partial=*/ false, - wire_len > 255, - ); - out.put_u8(*attr_flags); - out.put_u8(self.discriminant() as u8); - write_wire_len!(out, wire_len); - $attribute.to_wire(ctx, out)?; - }; - } - - macro_rules! write_wire_len { - ($out: expr, $wire_len: expr) => { - if $wire_len > 255 { - $out.put_u16($wire_len as u16); - } else { - $out.put_u8($wire_len as u8); - } - }; - } - - match self { - PathAttribute::Origin(origin_path_attribute) => { - write_path_attribute!(out, origin_path_attribute); - } - PathAttribute::AsPath(as_path_attribute) => { - write_path_attribute!(out, as_path_attribute); - } - PathAttribute::NextHop(next_hop_path_attribute) => { - write_path_attribute!(out, next_hop_path_attribute); - } - PathAttribute::MultiExitDisc(multi_exit_disc_path_attribute) => { - write_path_attribute!(out, multi_exit_disc_path_attribute); - } - PathAttribute::LocalPref(local_pref_path_attribute) => { - write_path_attribute!(out, local_pref_path_attribute); - } - PathAttribute::AtomicAggregate(atomic_aggregate_path_attribute) => { - write_path_attribute!(out, atomic_aggregate_path_attribute); - } - PathAttribute::Aggregator(aggregator_path_attribute) => { - write_path_attribute!(out, aggregator_path_attribute); - } - PathAttribute::Communitites(communities_path_attribute) => { - write_path_attribute!(out, communities_path_attribute); - } - PathAttribute::MpReachNlri(mp_reach_nlri_path_attribute) => { - write_path_attribute!(out, mp_reach_nlri_path_attribute); - } - PathAttribute::MpUnreachNlri(mp_unreach_nlri_path_attribute) => { - write_path_attribute!(out, mp_unreach_nlri_path_attribute); - } - PathAttribute::ExtendedCommunities(extended_communities_path_attribute) => { - write_path_attribute!(out, extended_communities_path_attribute); - } - PathAttribute::LargeCommunities(large_communities_path_attribute) => { - write_path_attribute!(out, large_communities_path_attribute); - } - PathAttribute::UnknownPathAttribute { + _ => Self::UnknownPathAttribute { flags, type_code, - payload, - } => { - out.put_u8(flags.0); - out.put_u8(*type_code); - if flags.extended_length() { - out.put_u16(payload.len() as u16); - } else { - out.put_u8(payload.len() as u8); - } - out.put(payload.as_slice()); - } - } + payload: value.to_vec(), + }, + }) + } + /// Writes the attribute, header and value, to the end of `out`. + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + // A header with a one-octet length first; whether the value needs + // the two-octet one is known once it is written. + let start = out.len(); + let mut flags = self.flags(); + out.put_u8(0); + out.put_u8(self.type_code()); + out.put_u8(0); + let value_start = out.len(); + self.value_to_wire(ctx, out)?; + let length = out.len() - value_start; + + if let Ok(short) = u8::try_from(length) + && !flags.extended_length() + { + out[start + 2] = short; + } else { + let long = u16::try_from(length).map_err(|_| EncodeError::TooLong { + what: "path attribute", + len: length, + max: u16::MAX.into(), + })?; + flags.set_extended_length(true); + // Move the value up by one octet to make room. + out.put_u8(0); + out.copy_within(value_start..value_start + length, value_start + 1); + out[start + 2..start + 4].copy_from_slice(&long.to_be_bytes()); + } + out[start] = flags.0; Ok(()) } - /// Returns the total on-wire length of this path attribute, including the - /// attribute header (flags + type + length) and the payload. - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - let payload_len: u16 = match self { - PathAttribute::Origin(a) => a.wire_len(ctx)?, - PathAttribute::AsPath(a) => a.wire_len(ctx)?, - PathAttribute::NextHop(a) => a.wire_len(ctx)?, - PathAttribute::MultiExitDisc(a) => a.wire_len(ctx)?, - PathAttribute::LocalPref(a) => a.wire_len(ctx)?, - PathAttribute::AtomicAggregate(a) => a.wire_len(ctx)?, - PathAttribute::Aggregator(a) => a.wire_len(ctx)?, - PathAttribute::Communitites(a) => a.wire_len(ctx)?, - PathAttribute::MpReachNlri(a) => a.wire_len(ctx)?, - PathAttribute::MpUnreachNlri(a) => a.wire_len(ctx)?, - PathAttribute::ExtendedCommunities(a) => a.wire_len(ctx)?, - PathAttribute::LargeCommunities(a) => a.wire_len(ctx)?, - PathAttribute::UnknownPathAttribute { flags, payload, .. } => { - // An unknown attribute preserves its original extended_length - // flag on re-serialization, so use it verbatim here rather than - // re-deriving it from the payload length. - let payload_len = u16::try_from(payload.len()) - .map_err(|_| eyre!("UnknownPathAttribute payload exceeds u16::MAX"))?; - let header_len: u16 = if flags.extended_length() { 4 } else { 3 }; - return Ok(header_len + payload_len); - } - }; - let header_len: u16 = if payload_len > u8::MAX as u16 { 4 } else { 3 }; - Ok(header_len + payload_len) + fn value_to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + match self { + Self::Origin(a) => a.to_wire(out), + Self::AsPath(a) => a.to_wire(ctx, out)?, + Self::NextHop(a) => a.to_wire(out), + Self::MultiExitDisc(a) => a.to_wire(out), + Self::LocalPref(a) => a.to_wire(out), + Self::AtomicAggregate(_) => {} + Self::Aggregator(a) => a.to_wire(ctx, out), + Self::Communities(a) => a.to_wire(out), + Self::MpReachNlri(a) => a.to_wire(ctx, out), + Self::MpUnreachNlri(a) => a.to_wire(ctx, out), + Self::ExtendedCommunities(a) => a.to_wire(out), + Self::As4Path(a) => a.to_wire_with_width(true, out)?, + Self::LargeCommunities(a) => a.to_wire(out), + Self::UnknownPathAttribute { payload, .. } => out.put_slice(payload), + } + Ok(()) } } -/// Origin path attribute is a mandatory attribute defined in RFC4271. -#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize_repr, Deserialize_repr)] +type Parsed<'a, T> = IResult<&'a [u8], T, BgpParserError<&'a [u8]>>; + +fn malformed<'a>(detail: String) -> nom::Err> { + Failure(BgpParserError::Malformed(detail)) +} + +/// ORIGIN: how the route came into BGP (RFC 4271 §5.1.1). +#[derive(Debug, PartialEq, Eq, Copy, Clone, Serialize_repr)] #[repr(u8)] pub enum OriginPathAttribute { IGP = 0, @@ -613,63 +693,34 @@ pub enum OriginPathAttribute { } impl OriginPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { let (buf, byte) = be_u8(buf)?; - let attr = match byte { - b if b == OriginPathAttribute::IGP as u8 => OriginPathAttribute::IGP, - b if b == OriginPathAttribute::EGP as u8 => OriginPathAttribute::EGP, - b if b == OriginPathAttribute::INCOMPLETE as u8 => OriginPathAttribute::INCOMPLETE, - _ => { - return IResult::Err(nom::Err::Failure(BgpParserError::Eyre(eyre!( - "Unknown Origin type {}", - byte - )))); - } + let origin = match byte { + 0 => Self::IGP, + 1 => Self::EGP, + 2 => Self::INCOMPLETE, + other => return Err(malformed(format!("unknown ORIGIN {other}"))), }; - Ok((buf, attr)) + Ok((buf, origin)) } - pub fn wire_len(&self, _ctx: &ParserContext) -> Result { - Ok(1) - } - - pub fn to_wire(&self, _ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { out.put_u8(*self as u8); - Ok(()) } } -impl TryFrom for OriginPathAttribute { - type Error = eyre::Error; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Self::IGP), - 1 => Ok(Self::EGP), - 2 => Ok(Self::INCOMPLETE), - other => bail!("Unexpected origin code {}", other), - } - } -} - -/// ASPathAttribute is a well-known mandatory attribute that contains a list of TLV encoded path -/// segments. Type is either 1 for AS_SET or 2 for AS_SEQUENCE, length is a 1 octet field -/// containing the number of ASNS and the value contains the ASNs. This is defined in Section 4.3 -/// of RFC4271. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +/// AS_PATH: the ASes a route has passed through, as a list of segments +/// (RFC 4271 §4.3). Each segment is a type (1 for AS_SET, 2 for +/// AS_SEQUENCE), a count in one octet, and that many AS numbers. +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct AsPathAttribute { pub segments: Vec, } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct AsPathSegment { - /// ordered is true when representing an AS_SEQUENCE, andd false when - /// representing an AS_SET. + /// True for an AS_SEQUENCE, false for an AS_SET. pub ordered: bool, - /// Path is the list of ASNs. pub path: Vec, } @@ -684,200 +735,157 @@ impl AsPathAttribute { }) } - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let parse_segment = |ctx: &ParserContext, - buf: &'a [u8]| - -> IResult<&'a [u8], AsPathSegment, BgpParserError<&'a [u8]>> { + /// Parses an AS_PATH with AS numbers as wide as the session negotiated + /// (RFC 6793). + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + Self::from_wire_with_width(ctx.four_octet_asn, buf) + } + + fn from_wire_with_width(four_octet: bool, buf: &[u8]) -> Parsed<'_, Self> { + let parse_segment = |buf| -> Parsed<'_, AsPathSegment> { let (buf, typ) = be_u8(buf)?; let (buf, len) = be_u8(buf)?; - let (buf, asns): (_, Vec) = match ctx.four_octet_asn { - Some(true) => { - nom::multi::many_m_n(len as usize, len as usize, be_u32).parse(buf)? - } - Some(false) => nom::multi::many_m_n(len as usize, len as usize, be_u16) + // AS_SET and AS_SEQUENCE. The confederation segment types of + // RFC 5065 are an error from a peer outside a confederation, + // and this speaker is never inside one. + if typ != 1 && typ != 2 { + return Err(malformed(format!( + "unrecognised AS path segment type {typ}" + ))); + } + if len == 0 { + return Err(malformed("AS path segment with no AS numbers".into())); + } + let count = usize::from(len); + let (buf, path): (_, Vec) = if four_octet { + nom::multi::many_m_n(count, count, be_u32).parse(buf)? + } else { + nom::multi::many_m_n(count, count, be_u16) .parse(buf) - .map(|(buf, asns)| (buf, asns.iter().map(|asn| *asn as u32).collect()))?, - None => { - return Err(nom::Err::Failure(BgpParserError::CustomText( - "Context must set four_octet_asn before being used", - ))); - } + .map(|(buf, asns)| (buf, asns.into_iter().map(u32::from).collect()))? }; - Ok(( buf, AsPathSegment { ordered: typ == 2, - path: asns, + path, }, )) }; - let (buf, segments) = - nom::multi::many0(|buf: &'a [u8]| parse_segment(ctx, buf)).parse(buf)?; - + // Not many0: that stops quietly at a segment it cannot read, and a + // truncated segment is an error. + let mut segments = Vec::new(); + let mut buf = buf; + while !buf.is_empty() { + let (rest, segment) = parse_segment(buf)?; + segments.push(segment); + buf = rest; + } Ok((buf, Self { segments })) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - if ctx.four_octet_asn.is_none_or(|val| !val) { - bail!("AsPathAttribute can only be sent for four_octet_asn enabled peers"); - } + /// Writes the path with AS numbers as wide as the session negotiated. + /// On a two-octet session an AS number that does not fit is written as + /// AS_TRANS (RFC 6793 §4.2.2); [`AsPathAttribute::as4_path`] is what + /// carries the real numbers alongside. + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<(), EncodeError> { + self.to_wire_with_width(ctx.four_octet_asn, out) + } + fn to_wire_with_width(&self, four_octet: bool, out: &mut BytesMut) -> Result<(), EncodeError> { for segment in &self.segments { - // Segment type. out.put_u8(if segment.ordered { 2 } else { 1 }); - // Segment AS length. - out.put_u8( - segment - .path - .len() - .try_into() - .wrap_err("AS Path length too long")?, - ); - // AS numbers. - match ctx - .four_octet_asn - .ok_or(eyre!("ctx.four_octet_asn must be set"))? - { - true => { - for asn in &segment.path { - out.put_u32(*asn); - } - } - false => { - for asn in &segment.path { - out.put_u16( - u16::try_from(*asn) - .map_err(|e| eyre!("AS number did not fit into u16: {}", e))?, - ); - } + let count = u8::try_from(segment.path.len()).map_err(|_| EncodeError::TooLong { + what: "AS path segment", + len: segment.path.len(), + max: u8::MAX.into(), + })?; + out.put_u8(count); + for asn in &segment.path { + if four_octet { + out.put_u32(*asn); + } else { + out.put_u16(u16::try_from(*asn).unwrap_or(AS_TRANS)); } } } - Ok(()) } - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - let mut counter = 0; - for segment in &self.segments { - counter += match ctx.four_octet_asn { - Some(true) => 2 + (4 * segment.path.len()), - Some(false) => 2 + (2 * segment.path.len()), - None => bail!("ParserContext needs four_octet_asn set"), - }; - } - Ok(counter as u16) + /// The AS4_PATH to send with this path on a two-octet session, if one is + /// needed: it is when some AS number does not fit in two octets and so + /// goes out as AS_TRANS in the AS_PATH itself (RFC 6793 §4.2.2). + pub fn as4_path(&self) -> Option { + self.segments + .iter() + .flat_map(|segment| &segment.path) + .any(|asn| u16::try_from(*asn).is_err()) + .then(|| PathAttribute::As4Path(self.clone())) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct NextHopPathAttribute(pub Ipv4Addr); impl NextHopPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, ip_u32) = be_u32(buf)?; - Ok((buf, Self(Ipv4Addr::from(ip_u32)))) + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, address) = be_u32(buf)?; + Ok((buf, Self(Ipv4Addr::from(address)))) } - pub fn to_wire(&self, _ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { out.put_u32(self.0.into()); - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(4) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct MultiExitDiscPathAttribute(pub u32); impl MultiExitDiscPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, val) = be_u32(buf)?; - Ok((buf, Self(val))) + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, value) = be_u32(buf)?; + Ok((buf, Self(value))) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { out.put_u32(self.0); - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(4) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct LocalPrefPathAttribute(pub u32); impl LocalPrefPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, val) = be_u32(buf)?; - Ok((buf, Self(val))) + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, value) = be_u32(buf)?; + Ok((buf, Self(value))) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { out.put_u32(self.0); - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(4) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +/// ATOMIC_AGGREGATE: a marker with no value (RFC 4271 §5.1.6). +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct AtomicAggregatePathAttribute {} -impl AtomicAggregatePathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - Ok((buf, Self {})) - } - - pub fn to_wire(&self, _: &ParserContext, _: &mut BytesMut) -> Result<()> { - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(0) - } -} - -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct AggregatorPathAttribute { pub asn: u32, pub ip: Ipv4Addr, } impl AggregatorPathAttribute { - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - if ctx.four_octet_asn.is_none_or(|val| !val) { - return Err(nom::Err::Failure(BgpParserError::CustomText( - "AggregatorPathAttribute can only be parsed for four_octet_asn enabled peers", - ))); - } - let (buf, asn) = be_u32(buf)?; + /// The AS number is as wide as the session's AS numbers (RFC 6793), so + /// the attribute is six or eight octets. + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, asn) = if ctx.four_octet_asn { + be_u32(buf)? + } else { + be_u16(buf).map(|(buf, asn)| (buf, u32::from(asn)))? + }; let (buf, ip) = be_u32(buf)?; Ok(( buf, @@ -888,297 +896,102 @@ impl AggregatorPathAttribute { )) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - if ctx.four_octet_asn.is_none_or(|val| !val) { - bail!("AggregatorPathAttribute can only be sent for four_octet_asn enabled peers"); + /// On a two-octet session an AS number that does not fit is written as + /// AS_TRANS (RFC 6793 §4.2.2). + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + if ctx.four_octet_asn { + out.put_u32(self.asn); + } else { + out.put_u16(u16::try_from(self.asn).unwrap_or(AS_TRANS)); } - out.put_u32(self.asn); out.put_u32(self.ip.into()); - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(8) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub struct CommunitiesPathAttribute(Vec<(u16, u16)>); +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] +pub struct CommunitiesPathAttribute(pub Vec<(u16, u16)>); impl CommunitiesPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, values) = nom::multi::many0(|i| (be_u16, be_u16).parse(i)).parse(buf)?; + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + // At least one: an empty COMMUNITIES is malformed (RFC 7606 §7.8). + let (buf, values) = nom::multi::many1(|i| (be_u16, be_u16).parse(i)).parse(buf)?; Ok((buf, CommunitiesPathAttribute(values))) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { for value in &self.0 { out.put_u16(value.0); out.put_u16(value.1); } - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok((self.0.len() * 4) as u16) } } -/// Extended Communities as defined in https://www.rfc-editor.org/rfc/rfc4360.html. -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +/// EXTENDED COMMUNITIES (RFC 4360). +#[derive(Debug, PartialEq, Eq, Clone, Serialize)] pub struct ExtendedCommunitiesPathAttribute { pub extended_communities: Vec, } impl ExtendedCommunitiesPathAttribute { - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, extended_communities) = - nom::multi::many1(|buf| ExtendedCommunity::from_wire(ctx, buf)).parse(buf)?; + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (communities, rest) = buf.as_chunks::<8>(); + if communities.is_empty() || !rest.is_empty() { + return Err(malformed(format!( + "{} octets are not a list of extended communities", + buf.len() + ))); + } Ok(( - buf, + rest, Self { - extended_communities, + extended_communities: communities.iter().copied().map(ExtendedCommunity).collect(), }, )) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - for ec in &self.extended_communities { - ec.to_wire(ctx, out)?; + pub fn to_wire(&self, out: &mut BytesMut) { + for community in &self.extended_communities { + out.put_slice(&community.0); } - Ok(()) - } - - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - Ok(self - .extended_communities - .iter() - .map(|ec| ec.wire_len(ctx)) - .sum::>()?) } } -#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] -pub enum ExtendedCommunity { - /// AS Specific Extended Community as specified in Section 3.1 of RFC4360. - AsSpecific { - typ: u8, - sub_typ: u8, - global_admin: u16, - local_admin: u32, - }, - /// Ipv4 Address Specific Extended Community as specified in Section 3.2 of RFC4360. - Ipv4AddrSpecific { - typ: u8, - sub_typ: u8, - global_admin: u32, - local_admin: u16, - }, - /// Opaque Extended Community as specified in Section 3.3 of RFC4360. - Opaque { - typ: u8, - sub_typ: u8, - value: [u8; 5], - }, - /// Route Target Community as specified in Section 4 of RFC4360. - RouteTarget { - typ: u8, - sub_typ: u8, - global_admin: u32, - local_admin: u16, - }, - /// Route Origin Community as specified in Section 5 of RFC4360. - RouteOrigin { - typ: u8, - sub_typ: u8, - global_admin: u16, - local_admin: u32, - }, -} +/// One extended community: eight octets, of which the first is the type +/// and, for most types, the second a sub-type (RFC 4360 §2). How the rest +/// divides into an administrator and a value depends on the type, and is +/// left to whoever acts on a particular community. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Serialize)] +pub struct ExtendedCommunity(pub [u8; 8]); impl ExtendedCommunity { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, typ) = be_u8(buf)?; - let (buf, sub_typ) = be_u8(buf)?; - let (buf, parsed) = match (typ, sub_typ) { - // Route Target Extended Community. - (0x00 | 0x01 | 0x02, 0x02) => { - let (buf, global_admin) = be_u32(buf)?; - let (buf, local_admin) = be_u16(buf)?; - ( - buf, - Self::RouteTarget { - typ, - sub_typ, - global_admin, - local_admin, - }, - ) - } - // Route Origin Extended Community. - (0x00 | 0x01 | 0x02, 0x03) => { - let (buf, global_admin) = be_u16(buf)?; - let (buf, local_admin) = be_u32(buf)?; - ( - buf, - Self::RouteOrigin { - typ, - sub_typ, - global_admin, - local_admin, - }, - ) - } - // AS specific Extended Community. - (0x00 | 0x40, _) => { - let (buf, global_admin) = be_u16(buf)?; - let (buf, local_admin) = be_u32(buf)?; - ( - buf, - Self::AsSpecific { - typ, - sub_typ, - global_admin, - local_admin, - }, - ) - } - // IPv4 Address Specific Extended Community. - (0x01 | 0x41, _) => { - let (buf, global_admin) = be_u32(buf)?; - let (buf, local_admin) = be_u16(buf)?; - ( - buf, - Self::Ipv4AddrSpecific { - typ, - sub_typ, - global_admin, - local_admin, - }, - ) - } - _ => { - let (buf, payload) = nom::bytes::take(5_usize).parse(buf)?; - let value: [u8; 5] = payload.try_into().map_err(|_| { - Failure(BgpParserError::CustomText( - "Expected exactly 5 bytes from the parser", - )) - })?; - ( - buf, - Self::Opaque { - typ, - sub_typ, - value, - }, - ) - } - }; - - return Ok((buf, parsed)); + pub fn typ(&self) -> u8 { + self.0[0] } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { - match self { - ExtendedCommunity::AsSpecific { - typ, - sub_typ, - global_admin, - local_admin, - } => { - out.put_u8(*typ); - out.put_u8(*sub_typ); - out.put_u16(*global_admin); - out.put_u32(*local_admin); - } - ExtendedCommunity::Ipv4AddrSpecific { - typ, - sub_typ, - global_admin, - local_admin, - } => { - out.put_u8(*typ); - out.put_u8(*sub_typ); - out.put_u32(*global_admin); - out.put_u16(*local_admin); - } - ExtendedCommunity::Opaque { - typ, - sub_typ, - value, - } => { - out.put_u8(*typ); - out.put_u8(*sub_typ); - out.put(&value[..]); - } - ExtendedCommunity::RouteTarget { - typ, - sub_typ, - global_admin, - local_admin, - } => { - out.put_u8(*typ); - out.put_u8(*sub_typ); - out.put_u32(*global_admin); - out.put_u16(*local_admin); - } - ExtendedCommunity::RouteOrigin { - typ, - sub_typ, - global_admin, - local_admin, - } => { - out.put_u8(*typ); - out.put_u8(*sub_typ); - out.put_u16(*global_admin); - out.put_u32(*local_admin); - } - } - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(8) + pub fn sub_typ(&self) -> u8 { + self.0[1] } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct LargeCommunitiesPathAttribute { pub communities: Vec, } impl LargeCommunitiesPathAttribute { - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, communities) = - nom::multi::many1(|buf| LargeCommunity::from_wire(ctx, buf)).parse(buf)?; + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, communities) = nom::multi::many1(LargeCommunity::from_wire).parse(buf)?; Ok((buf, Self { communities })) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { for community in &self.communities { - community.to_wire(ctx, out)?; + community.to_wire(out); } - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(12_u16 * self.communities.len() as u16) } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct LargeCommunity { pub global_admin: u32, pub data_1: u32, @@ -1186,10 +999,7 @@ pub struct LargeCommunity { } impl LargeCommunity { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { let (buf, global_admin) = be_u32(buf)?; let (buf, data_1) = be_u32(buf)?; let (buf, data_2) = be_u32(buf)?; @@ -1203,45 +1013,68 @@ impl LargeCommunity { )) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { + pub fn to_wire(&self, out: &mut BytesMut) { out.put_u32(self.global_admin); out.put_u32(self.data_1); out.put_u32(self.data_2); - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(12) } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// The next hop of routes in MP_REACH_NLRI. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum NlriNextHop { - /// Represents an IPv4 address nexthop. Ipv4(Ipv4Addr), - /// Represents a global IPv6 nexthop. + /// A global IPv6 address. Ipv6(Ipv6Addr), - /// Represents a IPv6 Link Local and global address pair nexthop. + /// A global IPv6 address and a link-local one (RFC 2545 §3). Ipv6WithLl { global: Ipv6Addr, link_local: Ipv6Addr, }, } -impl NlriNextHop { - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { +impl std::fmt::Display for NlriNextHop { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - NlriNextHop::Ipv4(ipv4_addr) => out.put(&ipv4_addr.octets()[..]), - NlriNextHop::Ipv6(ipv6_addr) => out.put(&ipv6_addr.octets()[..]), + NlriNextHop::Ipv4(addr) => write!(f, "{addr}"), + NlriNextHop::Ipv6(addr) => write!(f, "{addr}"), NlriNextHop::Ipv6WithLl { global, link_local } => { - out.put(&global.octets()[..]); - out.put(&link_local.octets()[..]) + write!(f, "{global} (link-local {link_local})") } } + } +} - Ok(()) +impl NlriNextHop { + /// Reads a next hop that is all of `bytes`. `None` if they are not the + /// length of one. + pub fn from_bytes(bytes: &[u8]) -> Option { + let v6 = |bytes: &[u8]| <[u8; 16]>::try_from(bytes).ok().map(Ipv6Addr::from); + match bytes.len() { + 4 => <[u8; 4]>::try_from(bytes) + .ok() + .map(|a| Self::Ipv4(a.into())), + 16 => v6(bytes).map(Self::Ipv6), + 32 => Some(Self::Ipv6WithLl { + global: v6(&bytes[..16])?, + link_local: v6(&bytes[16..])?, + }), + _ => None, + } } + pub fn to_wire(&self, out: &mut BytesMut) { + match self { + NlriNextHop::Ipv4(address) => out.put_slice(&address.octets()), + NlriNextHop::Ipv6(address) => out.put_slice(&address.octets()), + NlriNextHop::Ipv6WithLl { global, link_local } => { + out.put_slice(&global.octets()); + out.put_slice(&link_local.octets()); + } + } + } + + /// How many octets the next hop is on the wire. pub fn wire_len(&self) -> u8 { match self { NlriNextHop::Ipv4(_) => 4, @@ -1251,182 +1084,132 @@ impl NlriNextHop { } } -// parse_prefix is a helper function that implements an NLRI parser for the given AFI. -fn parse_prefix<'a>( - afi: AddressFamilyId, - buf: &'a [u8], -) -> IResult<&'a [u8], IpPrefix, BgpParserError<&'a [u8]>> { +/// Parses one prefix as an UPDATE names it: with a path identifier in +/// front if the session uses them for the family (RFC 7911 §3). +fn parse_nlri(version: IpVersion, with_path_id: bool, buf: &[u8]) -> Parsed<'_, Nlri> { + let (buf, path_id) = if with_path_id { be_u32(buf)? } else { (buf, 0) }; let (buf, prefix_len) = be_u8(buf)?; - let byte_len = (prefix_len + 7) / 8; - let (buf, prefix_bytes) = nom::bytes::take(byte_len as usize).parse(buf)?; - let prefix = IpPrefix::new(afi, prefix_bytes.to_vec(), prefix_len) - .map_err(|e| Failure(BgpParserError::Eyre(e)))?; - Ok((buf, prefix)) + let byte_len = usize::from(prefix_len.div_ceil(8)); + let (buf, prefix_bytes) = nom::bytes::take(byte_len).parse(buf)?; + let prefix = + IpPrefix::new(version, prefix_bytes, prefix_len).map_err(|e| malformed(e.to_string()))?; + Ok((buf, Nlri { prefix, path_id })) } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// Parses a field that is nothing but prefixes, to its end. +pub(crate) fn parse_nlri_list( + version: IpVersion, + with_path_id: bool, + mut buf: &[u8], +) -> Parsed<'_, Vec> { + // Not many0: that stops quietly at a prefix it cannot read. + let mut nlri = Vec::new(); + while !buf.is_empty() { + let (rest, parsed) = parse_nlri(version, with_path_id, buf)?; + nlri.push(parsed); + buf = rest; + } + Ok((buf, nlri)) +} + +/// The IP version of the prefixes that make up the NLRI of `family`. +/// +/// Any family can be named in MP_REACH_NLRI and MP_UNREACH_NLRI, but only +/// unicast and multicast routes of IPv4 and IPv6 are plain prefixes, and +/// those are the only NLRI this parser reads. A peer does not send routes +/// of a family that was not negotiated, so the others do not come up. +fn ip_version_of_nlri<'a>( + family: AfiSafi, +) -> Result>> { + let unsupported = || malformed(format!("NLRI of {family} is not supported")); + if family.safi != Safi::UNICAST && family.safi != Safi::MULTICAST { + return Err(unsupported()); + } + IpVersion::try_from(family.afi).map_err(|_| unsupported()) +} + +fn parse_family(buf: &[u8]) -> Parsed<'_, AfiSafi> { + let (buf, afi) = be_u16(buf)?; + let (buf, safi) = be_u8(buf)?; + Ok((buf, AfiSafi::new(Afi(afi), Safi(safi)))) +} + +/// MP_REACH_NLRI: routes of a family other than the one in the message +/// body, with their next hop (RFC 4760 §3). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct MpReachNlriPathAttribute { - pub afi: AddressFamilyId, - pub safi: SubsequentAfi, - /// Next hop address (either IPv4 or IPv6 for now). + pub family: AfiSafi, pub next_hop: NlriNextHop, - pub prefixes: Vec, + pub prefixes: Vec, } impl MpReachNlriPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, raw_afi) = be_u16(buf)?; - let afi = - AddressFamilyId::try_from(raw_afi).map_err(|e| Failure(BgpParserError::Eyre(e)))?; + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, family) = parse_family(buf)?; + let version = ip_version_of_nlri(family)?; - let (buf, raw_safi) = be_u8(buf)?; - let safi = - SubsequentAfi::try_from(raw_safi).map_err(|e| Failure(BgpParserError::Eyre(e)))?; - - let (buf, nh_len) = be_u8(buf)?; - - let (buf, next_hop, prefixes) = match afi { - AddressFamilyId::Ipv4 => { - // Read the length of the nexthop which should equal 4. - if nh_len != 4 { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Got nexthop address length {} when expected 4 for IPv4 AFI", - nh_len - )))); - } - // Read the nexthop address which should now be an IPv4 address. - let (buf, nh_bytes) = be_u32(buf)?; - let next_hop = NlriNextHop::Ipv4(Ipv4Addr::from(nh_bytes)); - - let (buf, prefixes) = - nom::multi::many0(|buf| parse_prefix(AddressFamilyId::Ipv4, buf)).parse(buf)?; - (buf, next_hop, prefixes) - } - AddressFamilyId::Ipv6 => { - // https://datatracker.ietf.org/doc/html/rfc2545 defines that the nexthop address may be 16 or 32 bytes long. - let (buf, nh_bytes) = nom::bytes::take(nh_len as usize).parse(buf)?; - let nexthop = match nh_bytes.len() { - 16 => { - // unwrap should never fire since we have explicitly checked the length. - let slice: [u8; 16] = nh_bytes.try_into().unwrap(); - NlriNextHop::Ipv6(Ipv6Addr::from(slice)) - } - 32 => { - // unwrap should never fire since we have explicitly checked the length. - let slice: [u8; 32] = nh_bytes.try_into().unwrap(); - let global_bytes: [u8; 16] = slice[0..16].try_into().unwrap(); - let global = Ipv6Addr::from(global_bytes); - let link_local_bytes: [u8; 16] = slice[16..32].try_into().unwrap(); - let link_local = Ipv6Addr::from(link_local_bytes); - NlriNextHop::Ipv6WithLl { global, link_local } - } - _ => { - return Err(Failure(BgpParserError::Eyre(eyre!( - "Mismatched IPv6 nexthop length, got {}, want 16 or 32", - nh_bytes.len() - )))); - } - }; - // Reserved 0 byte (formerly SNPA). - let (buf, _) = be_u8(buf)?; - let (buf, prefixes) = - nom::multi::many0(|buf| parse_prefix(AddressFamilyId::Ipv6, buf)).parse(buf)?; - (buf, nexthop, prefixes) - } - }; + let (buf, next_hop_len) = be_u8(buf)?; + let (buf, next_hop) = nom::bytes::take(usize::from(next_hop_len)).parse(buf)?; + // An IPv4 address for IPv4 routes; for IPv6, a global address, + // optionally followed by a link-local one (RFC 2545). + let next_hop = NlriNextHop::from_bytes(next_hop) + .filter(|next_hop| { + matches!(next_hop, NlriNextHop::Ipv4(_)) == (version == IpVersion::Ipv4) + }) + .ok_or_else(|| { + malformed(format!( + "a next hop of {next_hop_len} octets is not valid for {family}" + )) + })?; + // Reserved octet (formerly the SNPA count), for every family. + let (buf, _) = be_u8(buf)?; + let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; Ok(( buf, Self { - afi, - safi, + family, next_hop, prefixes, }, )) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - out.put_u16(self.afi as u16); - out.put_u8(self.safi as u8); + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + out.put_u16(self.family.afi.0); + out.put_u8(self.family.safi.0); out.put_u8(self.next_hop.wire_len()); - self.next_hop.to_wire(ctx, out)?; + self.next_hop.to_wire(out); out.put_u8(0); - for prefix in &self.prefixes { - out.put_u8(prefix.length); - out.put(&prefix.prefix[..]); - } - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(4_u16 - + self.next_hop.wire_len() as u16 - + 1 // Reserved byte (SNPA). - + self - .prefixes - .iter() - .map(|p| 1 + p.prefix.len() as u16) - .sum::()) + write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// MP_UNREACH_NLRI: withdrawals of routes of a family other than the one +/// in the message body (RFC 4760 §4). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct MpUnreachNlriPathAttribute { - pub afi: AddressFamilyId, - pub safi: SubsequentAfi, - pub prefixes: Vec, + pub family: AfiSafi, + pub prefixes: Vec, } impl MpUnreachNlriPathAttribute { - pub fn from_wire<'a>( - _: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, raw_afi) = be_u16(buf)?; - let afi = - AddressFamilyId::try_from(raw_afi).map_err(|e| Failure(BgpParserError::Eyre(e)))?; - - let (buf, raw_safi) = be_u8(buf)?; - let safi = - SubsequentAfi::try_from(raw_safi).map_err(|e| Failure(BgpParserError::Eyre(e)))?; - - let (buf, prefixes) = nom::multi::many0(|buf| parse_prefix(afi, buf)).parse(buf)?; - - Ok(( - buf, - MpUnreachNlriPathAttribute { - afi, - safi, - prefixes, - }, - )) + pub fn from_wire<'a>(ctx: &ParserContext, buf: &'a [u8]) -> Parsed<'a, Self> { + let (buf, family) = parse_family(buf)?; + let version = ip_version_of_nlri(family)?; + let (buf, prefixes) = parse_nlri_list(version, ctx.has_add_path(family), buf)?; + Ok((buf, Self { family, prefixes })) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<()> { - out.put_u16(self.afi as u16); - out.put_u8(self.safi as u8); - for prefix in &self.prefixes { - out.put_u8(prefix.length); - out.put(&prefix.prefix[..]); - } - Ok(()) - } - - pub fn wire_len(&self, _: &ParserContext) -> Result { - Ok(3 + self - .prefixes - .iter() - .map(|p| 1 + p.prefix.len() as u16) - .sum::()) + pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) { + out.put_u16(self.family.afi.0); + out.put_u8(self.family.safi.0); + write_nlri(&self.prefixes, ctx.has_add_path(self.family), out); } } /// Represents a BGP NOTIFICATION message per RFC 4271 §4.5. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct NotificationMessage { pub error: BgpError, /// Optional diagnostic data; may be empty. @@ -1434,37 +1217,12 @@ pub struct NotificationMessage { } impl NotificationMessage { - /// Parses a NOTIFICATION message body (without the 19-byte BGP header). - /// Consumes the entire buffer; any remaining bytes become `data`. - pub fn from_wire<'a>( - _ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, error_code) = be_u8(buf)?; - let (buf, error_subcode) = be_u8(buf)?; - - let error = BgpError::from_code_subcode(error_code, error_subcode) - .map_err(|e| Failure(BgpParserError::Eyre(e.into())))?; - - Ok(( - &[], - Self { - error, - data: buf.to_vec(), - }, - )) - } - - pub fn to_wire(&self, _ctx: &ParserContext, buf: &mut BytesMut) -> Result<()> { + /// Writes the body of the NOTIFICATION. + pub fn to_wire(&self, out: &mut BytesMut) { let (code, subcode) = self.error.code_subcode(); - buf.put_u8(code); - buf.put_u8(subcode); - buf.put_slice(&self.data); - Ok(()) - } - - pub fn wire_len(&self) -> u16 { - 2 + self.data.len() as u16 + out.put_u8(code); + out.put_u8(subcode); + out.put_slice(&self.data); } } @@ -1488,8 +1246,8 @@ mod tests { }; use bgp_primitives::{ - constants::{AddressFamilyId, SubsequentAfi}, - ip_prefix::IpPrefix, + constants::{AfiSafi, IpVersion}, + ip_prefix::{IpPrefix, Nlri}, parser::ParserContext, }; @@ -1517,11 +1275,12 @@ mod tests { } /// Creates a default context for evaluating the test cases below. - /// This uses `four_octet_asn` set to true and address_family to IPv6. + /// A session with four-octet AS numbers and an external peer. fn default_v6_context() -> ParserContext { ParserContext { - four_octet_asn: Some(true), - address_family: Some(AddressFamilyId::Ipv6), + four_octet_asn: true, + ibgp: false, + add_path: Vec::new(), } } @@ -1604,7 +1363,7 @@ mod tests { // Atomic Aggregate Path Attribute. test_path_attribute_roundtrip!( test_atomic_aggregate, - &[0xc0, 0x06, 0x00], + &[0x40, 0x06, 0x00], PathAttribute::AtomicAggregate(AtomicAggregatePathAttribute {}) ); @@ -1612,7 +1371,7 @@ mod tests { test_path_attribute_roundtrip!( test_aggregator, &[ - 0x80, 0x07, 0x08, 0x00, 0x00, 0xfd, 0xe8, 0xc0, 0xa8, 0x01, 0x01 + 0xc0, 0x07, 0x08, 0x00, 0x00, 0xfd, 0xe8, 0xc0, 0xa8, 0x01, 0x01 ], PathAttribute::Aggregator(AggregatorPathAttribute { asn: 65000, @@ -1624,7 +1383,7 @@ mod tests { test_path_attribute_roundtrip!( test_communities, &[0xc0, 0x08, 0x04, 0xca, 0xfe, 0xba, 0xbe], - PathAttribute::Communitites(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)])) + PathAttribute::Communities(CommunitiesPathAttribute(vec![(0xcafe, 0xbabe)])) ); // MP Reach NLRI Path Attribute. @@ -1637,17 +1396,16 @@ mod tests { 0x01, 0x0d, 0xb8 ], PathAttribute::MpReachNlri(MpReachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, + family: AfiSafi::IPV6_UNICAST, next_hop: NlriNextHop::Ipv6WithLl { global: ipv6!("2001:db8::1"), link_local: ipv6!("fe80::12"), }, - prefixes: vec![IpPrefix { - address_family: AddressFamilyId::Ipv6, - prefix: vec![0x20, 0x01, 0x0d, 0xb8], - length: 32, - }] + prefixes: vec![ + IpPrefix::new(IpVersion::Ipv6, [0x20, 0x01, 0x0d, 0xb8], 32) + .unwrap() + .into() + ] }) ); @@ -1658,13 +1416,12 @@ mod tests { 0x80, 0x0f, 0x08, 0x00, 0x02, 0x01, 0x20, 0x20, 0x01, 0x0d, 0xb8 ], PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute { - afi: AddressFamilyId::Ipv6, - safi: SubsequentAfi::Unicast, - prefixes: vec![IpPrefix { - address_family: AddressFamilyId::Ipv6, - prefix: vec![0x20, 0x01, 0x0d, 0xb8], - length: 32, - }], + family: AfiSafi::IPV6_UNICAST, + prefixes: vec![ + IpPrefix::new(IpVersion::Ipv6, [0x20, 0x01, 0x0d, 0xb8], 32) + .unwrap() + .into() + ], }) ); @@ -1677,24 +1434,9 @@ mod tests { ], PathAttribute::ExtendedCommunities(ExtendedCommunitiesPathAttribute { extended_communities: vec![ - ExtendedCommunity::RouteOrigin { - typ: 0, - sub_typ: 3, - global_admin: 65000, - local_admin: 1337 - }, - ExtendedCommunity::RouteOrigin { - typ: 0, - sub_typ: 3, - global_admin: 65001, - local_admin: 1337 - }, - ExtendedCommunity::RouteOrigin { - typ: 2, - sub_typ: 3, - global_admin: 65000, - local_admin: 1337 - }, + ExtendedCommunity([0x00, 0x03, 0xfd, 0xe8, 0x00, 0x00, 0x05, 0x39]), + ExtendedCommunity([0x00, 0x03, 0xfd, 0xe9, 0x00, 0x00, 0x05, 0x39]), + ExtendedCommunity([0x02, 0x03, 0xfd, 0xe8, 0x00, 0x00, 0x05, 0x39]), ] }) ); @@ -1733,21 +1475,23 @@ mod tests { // -- UPDATE message roundtrip tests -- - /// Asserts that an UPDATE message roundtrips byte-for-byte and that - /// wire_len agrees with the length of the encoded output. + /// The UPDATE in `body`, as it is to be acted on. + fn decode_update(ctx: &ParserContext, body: &[u8]) -> Result { + Ok(UpdateMessage::decode(ctx, body)?.update) + } + + /// Asserts that an UPDATE message roundtrips byte-for-byte. macro_rules! test_update_roundtrip { ($name:ident, $input_bytes:expr, $expected:expr) => { #[test] fn $name() -> Result<()> { let ctx = &default_v6_context(); - let (rem, parsed) = UpdateMessage::from_wire(ctx, $input_bytes)?; - assert!(rem.is_empty(), "Expected no trailing bytes, got {:x?}", rem); + let parsed = decode_update(ctx, $input_bytes)?; assert_eq!(parsed, $expected); let mut out = BytesMut::with_capacity(u16::MAX as usize); parsed.to_wire(ctx, &mut out)?; assert_eq!(out.to_vec(), $input_bytes); - assert_eq!(parsed.wire_len(ctx)?, $input_bytes.len() as u16); Ok(()) } }; @@ -1755,11 +1499,7 @@ mod tests { // Wrapper to generate a IPv4 prefix used in tests below. fn v4_prefix(length: u8, bytes: &[u8]) -> IpPrefix { - IpPrefix { - address_family: AddressFamilyId::Ipv4, - prefix: bytes.to_vec(), - length, - } + IpPrefix::new(IpVersion::Ipv4, bytes, length).unwrap() } /// A small set of path attributes that commonly accompany an IPv4 unicast @@ -1806,7 +1546,7 @@ mod tests { UpdateMessage { withdrawn_routes: vec![], path_attributes: sample_ipv4_path_attributes(), - announced_routes: vec![v4_prefix(8, &[0x0a])], + announced_routes: vec![v4_prefix(8, &[0x0a]).into()], } ); @@ -1821,7 +1561,7 @@ mod tests { 0x00, 0x00, ], UpdateMessage { - withdrawn_routes: vec![v4_prefix(8, &[0x0a])], + withdrawn_routes: vec![v4_prefix(8, &[0x0a]).into()], path_attributes: vec![], announced_routes: vec![], } @@ -1844,11 +1584,11 @@ mod tests { 0x18, 0xc0, 0xa8, 0x01, ], UpdateMessage { - withdrawn_routes: vec![v4_prefix(8, &[0x0a])], + withdrawn_routes: vec![v4_prefix(8, &[0x0a]).into()], path_attributes: sample_ipv4_path_attributes(), announced_routes: vec![ - v4_prefix(16, &[0xac, 0x10]), - v4_prefix(24, &[0xc0, 0xa8, 0x01]), + v4_prefix(16, &[0xac, 0x10]).into(), + v4_prefix(24, &[0xc0, 0xa8, 0x01]).into(), ], } ); @@ -1874,13 +1614,272 @@ mod tests { ); // Rejects trailing garbage after the NLRI section (a partial prefix). + /// With ADD-PATH in use for a family, each of its prefixes has a + /// four-octet path identifier in front (RFC 7911 §3): in the body for + /// IPv4 unicast, in MP_REACH_NLRI and MP_UNREACH_NLRI for the others. + #[test] + fn prefixes_carry_a_path_identifier_where_add_path_is_in_use() { + let plain = default_v6_context(); + let v4_only = ParserContext { + add_path: vec![AfiSafi::IPV4_UNICAST], + ..default_v6_context() + }; + let v6_only = ParserContext { + add_path: vec![AfiSafi::IPV6_UNICAST], + ..default_v6_context() + }; + + // Withdrawing 10.0.0.0/8, path 7, in the body. + #[rustfmt::skip] + let body = [ + 0x00, 0x06, // withdrawn routes length + 0x00, 0x00, 0x00, 0x07, 0x08, 0x0a, // path 7, 10.0.0.0/8 + 0x00, 0x00, // no attributes + ]; + let update = decode_update(&v4_only, &body).unwrap(); + assert_eq!( + update.withdrawn_routes, + vec![Nlri { + prefix: v4_prefix(8, &[0x0a]), + path_id: 7, + }] + ); + let mut out = BytesMut::new(); + update.to_wire(&v4_only, &mut out).unwrap(); + assert_eq!(&out[..], &body[..]); + // The same route on a session without ADD-PATH has no identifier. + let mut out = BytesMut::new(); + update.to_wire(&plain, &mut out).unwrap(); + assert_eq!(&out[..], [0x00, 0x02, 0x08, 0x0a, 0x00, 0x00]); + // And ADD-PATH for IPv6 says nothing about the IPv4 prefixes in + // the body: read that way, these bytes are not prefixes at all. + assert!(decode_update(&v6_only, &body).is_err()); + + // Two routes for 2001:db8::/32, paths 1 and 2, withdrawn. + #[rustfmt::skip] + let unreach = [ + 0x00, 0x02, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x20, 0x20, 0x01, 0x0d, 0xb8, + 0x00, 0x00, 0x00, 0x02, 0x20, 0x20, 0x01, 0x0d, 0xb8, + ]; + let (rest, parsed) = MpUnreachNlriPathAttribute::from_wire(&v6_only, &unreach).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + parsed + .prefixes + .iter() + .map(|n| n.path_id) + .collect::>(), + [1, 2] + ); + assert_eq!(parsed.prefixes[0].prefix, parsed.prefixes[1].prefix); + let mut out = BytesMut::new(); + parsed.to_wire(&v6_only, &mut out); + assert_eq!(&out[..], &unreach[..]); + } + + // Found by the property tests in tests/properties.rs. + + /// A prefix longer than its address family allows was accepted, and + /// showing it (as the session does when it logs a route) panicked. A + /// peer could end the session task with one UPDATE. + #[test] + fn a_prefix_longer_than_an_address_is_malformed() { + let ctx = default_v6_context(); + // No withdrawn routes, no attributes, NLRI of length 99. + let mut body = vec![0, 0, 0, 0, 99]; + body.extend_from_slice(&[0xff; 13]); + assert!(decode_update(&ctx, &body).is_err()); + + let mut unreach = vec![0x00, 0x02, 0x01, 129]; + unreach.extend_from_slice(&[0xff; 17]); + assert!(MpUnreachNlriPathAttribute::from_wire(&ctx, &unreach).is_err()); + } + + /// The bits of a prefix past its length mean nothing (RFC 4271 §4.3). + /// They used to be kept, which made 10.0.0.0/8 sent with stray bits a + /// different route from 10.0.0.0/8. + #[test] + fn bits_past_the_prefix_length_are_cleared() { + let ctx = default_v6_context(); + // 10.255.0.0/12 withdrawn, with the low four bits of the second + // octet set. + let body = [0, 3, 12, 10, 0xff, 0, 0]; + let update = decode_update(&ctx, &body).unwrap(); + assert_eq!(update.withdrawn_routes, vec![v4_prefix(12, &[10, 0xf0])]); + assert_eq!(update.withdrawn_routes[0].to_string(), "10.240.0.0/12"); + } + + /// An extended community is eight octets whatever its type. The opaque + /// kind was read as seven, which put every community after it out of + /// step. + #[test] + fn an_opaque_extended_community_is_eight_octets() { + #[rustfmt::skip] + let wire = [ + 0x03, 0x0c, 1, 2, 3, 4, 5, 6, // opaque + 0x00, 0x02, 0, 0, 0xfd, 0xe8, 0, 100, // route target 65000:100 + ]; + let (rest, parsed) = ExtendedCommunitiesPathAttribute::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + parsed.extended_communities, + vec![ + ExtendedCommunity([0x03, 0x0c, 1, 2, 3, 4, 5, 6]), + ExtendedCommunity([0x00, 0x02, 0, 0, 0xfd, 0xe8, 0, 100]), + ] + ); + assert_eq!(parsed.extended_communities[0].typ(), 0x03); + assert_eq!(parsed.extended_communities[0].sub_typ(), 0x0c); + let mut out = BytesMut::new(); + parsed.to_wire(&mut out); + assert_eq!(&out[..], &wire[..]); + + // A list that is not a whole number of them is malformed. + assert!(ExtendedCommunitiesPathAttribute::from_wire(&wire[..12]).is_err()); + assert!(ExtendedCommunitiesPathAttribute::from_wire(&[]).is_err()); + } + + /// AGGREGATOR carries an AS number as wide as the session's. It could + /// neither be read from nor written to a two-octet peer, so one such + /// attribute ended the session. + #[test] + fn aggregator_is_six_octets_on_a_two_octet_session() { + let two_octet = ParserContext { + four_octet_asn: false, + ibgp: false, + add_path: Vec::new(), + }; + let wire = [0xfd, 0xe8, 192, 0, 2, 1]; + let (rest, parsed) = AggregatorPathAttribute::from_wire(&two_octet, &wire).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + parsed, + AggregatorPathAttribute { + asn: 65000, + ip: Ipv4Addr::new(192, 0, 2, 1), + } + ); + let mut out = BytesMut::new(); + parsed.to_wire(&two_octet, &mut out); + assert_eq!(&out[..], &wire[..]); + } + + /// The reserved octet after the next hop is there for every family. It + /// used to be skipped only for IPv6, so an IPv4 MP_REACH_NLRI gained a + /// 0.0.0.0/0 route that the peer never sent. + #[test] + fn mp_reach_for_ipv4_has_no_phantom_default_route() { + let ctx = default_v6_context(); + #[rustfmt::skip] + let wire = [ + 0x00, 0x01, 0x01, // IPv4 unicast + 0x04, 192, 0, 2, 1, // next hop + 0x00, // reserved + 0x18, 10, 1, 2, // 10.1.2.0/24 + ]; + let (rest, parsed) = MpReachNlriPathAttribute::from_wire(&ctx, &wire).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + parsed.family, + bgp_primitives::constants::AfiSafi::IPV4_UNICAST + ); + assert_eq!( + parsed.next_hop, + NlriNextHop::Ipv4(Ipv4Addr::new(192, 0, 2, 1)) + ); + assert_eq!(parsed.prefixes, vec![v4_prefix(24, &[10, 1, 2])]); + + let mut out = BytesMut::new(); + parsed.to_wire(&ctx, &mut out); + assert_eq!(&out[..], &wire[..]); + } + + /// Any family can be named, but only IP prefixes can be read as NLRI. + /// Reading the routes of another family as prefixes would invent routes. + #[test] + fn nlri_of_a_family_that_is_not_ip_prefixes_is_refused() { + let ctx = default_v6_context(); + // L2VPN EVPN, and IPv4 with labels: neither is a list of prefixes. + for family in [[0x00, 0x19, 0x46], [0x00, 0x01, 0x04]] { + let mut reach = family.to_vec(); + reach.extend_from_slice(&[0x04, 192, 0, 2, 1, 0x00, 0x18, 10, 1, 2]); + assert!(MpReachNlriPathAttribute::from_wire(&ctx, &reach).is_err()); + + let mut unreach = family.to_vec(); + unreach.extend_from_slice(&[0x18, 10, 1, 2]); + assert!(MpUnreachNlriPathAttribute::from_wire(&ctx, &unreach).is_err()); + } + } + + /// AS_PATH is as wide as the session negotiated; AS4_PATH is always + /// four octets wide (RFC 6793). + #[test] + fn as_paths_are_encoded_as_wide_as_the_session_allows() { + let path = AsPathAttribute { + segments: vec![AsPathSegment { + ordered: true, + path: vec![65000, 4_200_000_001], + }], + }; + let two_octet = ParserContext { + four_octet_asn: false, + ibgp: false, + add_path: Vec::new(), + }; + let four_octet = ParserContext { + four_octet_asn: true, + ibgp: false, + add_path: Vec::new(), + }; + let wire = |attribute: &PathAttribute, ctx: &ParserContext| { + let mut out = BytesMut::new(); + attribute.to_wire(ctx, &mut out).unwrap(); + out.to_vec() + }; + + // Two octets: the number that does not fit goes out as AS_TRANS. + let as_path = PathAttribute::AsPath(path.clone()); + #[rustfmt::skip] + assert_eq!( + wire(&as_path, &two_octet), + [0x40, 0x02, 0x06, 0x02, 0x02, 0xfd, 0xe8, 0x5b, 0xa0] + ); + #[rustfmt::skip] + assert_eq!( + wire(&as_path, &four_octet), + [0x40, 0x02, 0x0a, 0x02, 0x02, 0x00, 0x00, 0xfd, 0xe8, 0xfa, 0x56, 0xea, 0x01] + ); + + // AS4_PATH: optional transitive, type 17, the same on any session. + let as4_path = path.as4_path().expect("one AS number needs four octets"); + #[rustfmt::skip] + let expected = [0xc0, 0x11, 0x0a, 0x02, 0x02, 0x00, 0x00, 0xfd, 0xe8, 0xfa, 0x56, 0xea, 0x01]; + for ctx in [&two_octet, &four_octet] { + let bytes = wire(&as4_path, ctx); + assert_eq!(bytes, expected); + let (rest, parsed) = PathAttribute::from_wire(ctx, &bytes).unwrap(); + assert!(rest.is_empty()); + assert_eq!(parsed, as4_path); + } + + // A path of numbers that all fit needs no AS4_PATH. + let narrow = AsPathAttribute { + segments: vec![AsPathSegment { + ordered: true, + path: vec![65000, 65001], + }], + }; + assert_eq!(narrow.as4_path(), None); + } + #[test] fn test_update_rejects_trailing_garbage() { let ctx = default_v6_context(); // Well-formed withdrawn_len=0, pa_len=0, followed by a single byte that // declares a prefix length of 8 but provides no prefix bytes. let buf = &[0x00, 0x00, 0x00, 0x00, 0x08]; - let result = UpdateMessage::from_wire(&ctx, buf); + let result = decode_update(&ctx, buf); assert!( result.is_err(), "Expected a parse error for trailing garbage, got {:?}", @@ -1889,43 +1888,50 @@ mod tests { } #[test] - fn test_notification_roundtrip() -> Result<()> { - use crate::errors::{BgpError, CeaseSubcode}; + fn a_notification_round_trips_with_its_data() -> Result<()> { + use crate::errors::{BgpError, CeaseSubcode, FsmSubcode}; let ctx = &default_v6_context(); - let msg = NotificationMessage { - error: BgpError::Cease(CeaseSubcode::AdminShutdown), - data: vec![0xde, 0xad], - }; - - let mut buf = BytesMut::new(); - msg.to_wire(ctx, &mut buf)?; - assert_eq!(buf.as_ref(), &[6, 2, 0xde, 0xad]); - - let (rem, parsed) = NotificationMessage::from_wire(ctx, &buf).unwrap(); - assert!(rem.is_empty()); - assert_eq!(parsed, msg); + for (error, data, body) in [ + ( + BgpError::Cease(CeaseSubcode::AdminShutdown), + vec![0xde, 0xad], + &[6, 2, 0xde, 0xad][..], + ), + ( + BgpError::FsmError(FsmSubcode::UnexpectedEstablished), + vec![], + &[5, 3][..], + ), + ] { + let msg = Message::Notification(NotificationMessage { error, data }); + let mut buf = BytesMut::new(); + msg.to_wire(ctx, &mut buf)?; + assert_eq!(&buf[19..], body); + assert_eq!(Message::decode(ctx, &buf)?, msg); + } Ok(()) } + /// A peer may give a reason we have no name for: a subcode of zero + /// ("unspecific"), or one assigned since. Such a NOTIFICATION used to + /// be refused as malformed, which lost the reason and answered a + /// message that must not be answered. #[test] - fn test_notification_no_data() -> Result<()> { - use crate::errors::{BgpError, FsmSubcode}; + fn a_notification_with_an_unnamed_reason_is_still_read() { + use crate::errors::BgpError; - let ctx = &default_v6_context(); - let msg = NotificationMessage { - error: BgpError::FsmError(FsmSubcode::UnexpectedEstablished), - data: vec![], - }; - - let mut buf = BytesMut::new(); - msg.to_wire(ctx, &mut buf)?; - assert_eq!(buf.as_ref(), &[5, 3]); - - let (rem, parsed) = NotificationMessage::from_wire(ctx, &buf).unwrap(); - assert!(rem.is_empty()); - assert_eq!(parsed, msg); - Ok(()) + let ctx = default_v6_context(); + for (code, subcode) in [(6, 0), (5, 0), (2, 5), (3, 7), (4, 9), (99, 1)] { + let decoded = Message::decode(&ctx, &framed(21, 3, &[code, subcode])).unwrap(); + let Message::Notification(notification) = &decoded else { + panic!("expected a NOTIFICATION, got {decoded:?}"); + }; + assert_eq!(notification.error, BgpError::Other { code, subcode }); + assert_eq!(notification.error.code_subcode(), (code, subcode)); + } + // Too short to hold a code and subcode. + assert!(Message::decode(&ctx, &framed(20, 3, &[6])).is_err()); } #[test] @@ -1939,30 +1945,7 @@ mod tests { assert!(buf[..16].iter().all(|&b| b == 0xFF)); assert_eq!(&buf[16..19], &[0x00, 0x13, 0x04]); - let (rem, parsed) = Message::from_wire(ctx, &buf).unwrap(); - assert!(rem.is_empty()); - assert!(matches!(parsed, Message::KeepAlive)); - Ok(()) - } - - #[test] - fn test_message_notification_roundtrip() -> Result<()> { - use crate::errors::{BgpError, CeaseSubcode}; - - let ctx = &default_v6_context(); - let inner = NotificationMessage { - error: BgpError::Cease(CeaseSubcode::AdminShutdown), - data: vec![], - }; - let msg = Message::Notification(inner); - - let mut buf = BytesMut::new(); - msg.to_wire(ctx, &mut buf)?; - assert_eq!(buf.len(), msg.wire_len(ctx)? as usize); - - let (rem, parsed) = Message::from_wire(ctx, &buf).unwrap(); - assert!(rem.is_empty()); - assert!(matches!(parsed, Message::Notification(_))); + assert_eq!(Message::decode(ctx, &buf)?, Message::KeepAlive); Ok(()) } @@ -1973,7 +1956,7 @@ mod tests { buf[16] = 0x00; buf[17] = 0x13; buf[18] = 0x04; - assert!(Message::from_wire(&ctx, &buf).is_err()); + assert!(Message::decode(&ctx, &buf).is_err()); } #[test] @@ -1983,6 +1966,135 @@ mod tests { buf[16] = 0x00; buf[17] = 0x12; // length = 18, below minimum 19 buf[18] = 0x04; - assert!(Message::from_wire(&ctx, &buf).is_err()); + assert!(Message::decode(&ctx, &buf).is_err()); + } + + /// A message that cannot be encoded leaves nothing behind in the + /// buffer, so that what is already there can still be sent. + #[test] + fn a_message_that_cannot_be_encoded_leaves_the_buffer_as_it_was() { + let ctx = default_v6_context(); + let mut buf = BytesMut::new(); + Message::KeepAlive.to_wire(&ctx, &mut buf).unwrap(); + + let too_long = Message::Notification(NotificationMessage { + error: crate::errors::BgpError::HoldTimer, + data: vec![0; 5000], + }); + assert!(too_long.to_wire(&ctx, &mut buf).is_err()); + assert_eq!(buf.len(), 19); + } + + /// An attribute's length is one octet unless its value needs two. + #[test] + fn a_long_attribute_gets_the_extended_length() { + let ctx = default_v6_context(); + for (communities, extended) in [(63, false), (64, true)] { + let attribute = + PathAttribute::Communities(CommunitiesPathAttribute(vec![(1, 2); communities])); + let mut out = BytesMut::new(); + attribute.to_wire(&ctx, &mut out).unwrap(); + let header = if extended { 4 } else { 3 }; + assert_eq!(out.len(), header + 4 * communities); + assert_eq!(out[0] & 0x10 != 0, extended); + let (rest, parsed) = PathAttribute::from_wire(&ctx, &out).unwrap(); + assert!(rest.is_empty()); + assert_eq!(parsed, attribute); + } + } + + /// A header, with `length` and `msg_type` as given, followed by `body`. + fn framed(length: u16, msg_type: u8, body: &[u8]) -> Vec { + let mut buf = vec![0xFF; 16]; + buf.extend_from_slice(&length.to_be_bytes()); + buf.push(msg_type); + buf.extend_from_slice(body); + buf + } + + /// RFC 4271 §6.1 to §6.3: what is wrong decides the NOTIFICATION, and + /// for a bad length or type the Data field carries the offending value. + #[test] + fn decode_failures_name_the_notification_to_send() { + use crate::errors::{ + BgpError, MsgHeaderSubcode as Header, OpenMsgSubcode, UpdateMsgSubcode, + }; + let ctx = default_v6_context(); + let failure = |buf: Vec| { + let e = Message::decode(&ctx, &buf).unwrap_err(); + (e.notification, e.data) + }; + + let mut bad_marker = framed(19, 4, &[]); + bad_marker[3] = 0; + assert_eq!( + failure(bad_marker), + (BgpError::MsgHeader(Header::ConnNotSynchronized), vec![]) + ); + // Shorter than a header, longer than the maximum, longer than the + // bytes at hand: all a bad length, reported with that length. + for length in [18u16, 4097, 100] { + assert_eq!( + failure(framed(length, 4, &[])), + ( + BgpError::MsgHeader(Header::BadMessageLength), + length.to_be_bytes().to_vec() + ), + "length {length}" + ); + } + assert_eq!( + failure(framed(20, 4, &[0])), + (BgpError::MsgHeader(Header::BadMessageLength), vec![0, 20]), + "a KEEPALIVE has no body" + ); + assert_eq!( + failure(framed(19, 9, &[])), + (BgpError::MsgHeader(Header::BadMessageType), vec![9]) + ); + // An OPEN for BGP version 3: the reply says we speak version 4. + assert_eq!( + failure(framed(29, 1, &[3, 0xfd, 0xe8, 0, 90, 10, 0, 0, 1, 0])), + ( + BgpError::OpenMsg(OpenMsgSubcode::UnsupportedVersion), + vec![0, 4] + ) + ); + // The same OPEN for version 4 is fine: no optional parameters is + // a valid OPEN. + let plain = framed(29, 1, &[4, 0xfd, 0xe8, 0, 90, 10, 0, 0, 1, 0]); + assert!( + matches!(Message::decode(&ctx, &plain), Ok(Message::Open(open)) if open.options.is_empty()) + ); + // An OPEN cut short. + assert_eq!( + failure(framed(21, 1, &[4, 0xfd])).0, + BgpError::OpenMsg(OpenMsgSubcode::Unspecific) + ); + // An UPDATE whose attribute length points past its end. + assert_eq!( + failure(framed(23, 2, &[0, 0, 0, 40])).0, + BgpError::UpdateMsg(UpdateMsgSubcode::MalformedAttrs) + ); + } + + #[test] + fn decode_ignores_bytes_after_the_message() { + let ctx = default_v6_context(); + let mut buf = framed(19, 4, &[]); + buf.extend_from_slice(&[1, 2, 3]); + assert_eq!(Message::decode(&ctx, &buf), Ok(Message::KeepAlive)); + } + + /// The optional parameters are the last thing in an OPEN. + #[test] + fn bytes_after_the_optional_parameters_of_an_open_are_refused() { + use crate::errors::{BgpError, OpenMsgSubcode}; + let ctx = default_v6_context(); + let open = framed(31, 1, &[4, 0xfd, 0xe8, 0, 90, 10, 0, 0, 1, 0, 0xaa, 0xbb]); + assert_eq!( + Message::decode(&ctx, &open).unwrap_err().notification, + BgpError::OpenMsg(OpenMsgSubcode::Unspecific) + ); } } diff --git a/crates/packet/src/open.rs b/crates/packet/src/open.rs index 0d33fbd..b6773f2 100644 --- a/crates/packet/src/open.rs +++ b/crates/packet/src/open.rs @@ -2,30 +2,47 @@ use std::net::Ipv4Addr; use bitfield::bitfield; use bytes::{BufMut, BytesMut}; -use eyre::{Result, bail, eyre}; use nom::{ Err::Failure, IResult, Parser, - combinator::peek, number::complete::{be_u8, be_u16, be_u24, be_u32}, }; -use serde::{Deserialize, Serialize}; -use strum::EnumDiscriminants; +use serde::Serialize; + +use crate::errors::InvalidValue; use bgp_primitives::{ - constants::{AddressFamilyId, SubsequentAfi}, - parser::{BgpParserError, ParserContext, ProtocolErrorValues}, + constants::{Afi, AfiSafi, Safi}, + parser::{BgpParserError, EncodeError, ProtocolErrorValues}, }; +type Parsed<'a, T> = IResult<&'a [u8], T, BgpParserError<&'a [u8]>>; + macro_rules! fail { ( $($arg:expr),* $(,)? ) => { - Failure(BgpParserError::Eyre(eyre!($($arg),*))) + Failure(BgpParserError::Malformed(format!($($arg),*))) }; } -/// Contains messages related to the BGP Open message, -/// i.e. the Open message itself and options / capabilities. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +/// Parses `buf` to its end as a list of `item`s. Unlike nom's `many0`, +/// which stops quietly at an item it cannot read, an error in an item is an +/// error of the list. +fn list<'a, T>(mut buf: &'a [u8], item: impl Fn(&'a [u8]) -> Parsed<'a, T>) -> Parsed<'a, Vec> { + let mut items = Vec::new(); + while !buf.is_empty() { + let (rest, parsed) = item(buf)?; + items.push(parsed); + buf = rest; + } + Ok((buf, items)) +} + +/// The optional parameter type that marks an OPEN whose optional parameters +/// have two-octet lengths (RFC 9072). +const EXTENDED_LENGTH: u8 = 255; + +/// The OPEN message (RFC 4271 §4.2). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct OpenMessage { /// Version of the BGP protocol in use. pub version: u8, @@ -40,17 +57,14 @@ pub struct OpenMessage { } impl OpenMessage { - /// The from_wire implementation parses a BGP Open message from a buffer - /// containing the payload of the open message (i.e. without the BGP header / type / length) - /// and the buf passed in must contain only the open message. - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { + /// Parses the body of an OPEN (what follows the 19-octet header). + /// Returns what is left of `buf` after the optional parameters, which + /// in a well-formed OPEN is nothing. + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { let (buf, version) = be_u8(buf)?; if version != 4 { return Err(Failure(BgpParserError::ProtocolError( - ProtocolErrorValues::UnknownOpenOption(version), + ProtocolErrorValues::UnsupportedVersion(version), ))); } @@ -58,30 +72,22 @@ impl OpenMessage { let (buf, hold_time) = be_u16(buf)?; let (buf, identifier) = be_u32(buf)?; - // RFC 9072: Extended Open Options length. - // To encode a capabilities length greater than 255 the packet structure - // is prefixed with the extended length option (255), a length of 255, - // and then a u16 containing the payload option. + // RFC 9072: when the first parameter type is 255, the real length + // of the optional parameters follows in two octets, and each + // parameter has a two-octet length as well. An OPEN without + // optional parameters ends at the length octet. let (buf, opt_len) = be_u8(buf)?; - let (_, inner) = peek(be_u8).parse(buf)?; - - let (buf, opt_len) = if inner == 255 { - let (buf, _) = be_u8(buf)?; // Skip past the inner type. - be_u16(buf)? + let extended = opt_len != 0 && buf.first() == Some(&EXTENDED_LENGTH); + let (buf, opt_len) = if extended { + let (buf, _) = be_u8(buf)?; + let (buf, opt_len) = be_u16(buf)?; + (buf, usize::from(opt_len)) } else { - (buf, opt_len as u16) + (buf, usize::from(opt_len)) }; let (buf, opt_buf) = nom::bytes::take(opt_len).parse(buf)?; - let (opt_buf, options) = - nom::multi::many0(|buf| OpenOption::from_wire(ctx, buf)).parse(opt_buf)?; - - if !opt_buf.is_empty() { - return Err(fail!( - "Leftover bytes after parsing open options: {:x?}", - opt_buf - )); - } + let (_, options) = list(opt_buf, |buf| OpenOption::from_wire(extended, buf))?; Ok(( buf, @@ -95,402 +101,389 @@ impl OpenMessage { )) } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { + /// Every capability in the message, across all the optional parameters + /// that carry them. + pub fn capabilities(&self) -> impl Iterator { + self.options.iter().flat_map(|option| match option { + OpenOption::Capabilities(capabilities) => capabilities.iter(), + }) + } + + /// Writes the body of the OPEN. + pub fn to_wire(&self, out: &mut BytesMut) -> Result<(), EncodeError> { out.put_u8(self.version); out.put_u16(self.asn); out.put_u16(self.hold_time); out.put_u32(self.identifier.into()); - // RFC 9072: Depending on whether the options fit in < 255 bytes - // we encode the length accordingly. - let opt_len = self + // The parameters are written out first: how long they are decides + // which form of lengths the message uses (RFC 9072 §2). + let parameters = self .options .iter() - .map(|o| o.wire_len(ctx)) - .sum::>()?; - if opt_len > u8::MAX as u16 { - out.put_u8(0xff); // Set length to 255 as per RFC 9072. - out.put_u8(0xff); // Open option type for Ext length. - out.put_u16(opt_len); + .map(|option| Ok((option.type_code(), option.value()?))) + .collect::, EncodeError>>()?; + let total = |header: usize| -> usize { + parameters + .iter() + .map(|(_, value)| header + value.len()) + .sum() + }; + let fits_one_octet = parameters.iter().all(|(_, value)| value.len() <= 255); + + if let Ok(length) = u8::try_from(total(2)) + && fits_one_octet + { + out.put_u8(length); + for (type_code, value) in ¶meters { + out.put_u8(*type_code); + out.put_u8(value.len() as u8); + out.put_slice(value); + } } else { - out.put_u8(opt_len as u8); + let too_long = |len| EncodeError::TooLong { + what: "OPEN optional parameters", + len, + max: u16::MAX.into(), + }; + let length = u16::try_from(total(3)).map_err(|_| too_long(total(3)))?; + out.put_u8(255); + out.put_u8(EXTENDED_LENGTH); + out.put_u16(length); + for (type_code, value) in ¶meters { + out.put_u8(*type_code); + // No longer than the total, which fits. + out.put_u16(value.len() as u16); + out.put_slice(value); + } } - - for option in &self.options { - option.to_wire(ctx, out)?; - } - Ok(()) } - - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - let mut total = 10; - let opt_len = self - .options - .iter() - .map(|o| o.wire_len(ctx)) - .sum::>()?; - if opt_len > u8::MAX as u16 { - total += opt_len + 3; // 1+2 bytes extra for the type + extended length. - } else { - total += opt_len; - } - Ok(total) - } } -#[derive(Debug, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] -#[repr(u8)] +/// The optional parameter type for capabilities (RFC 5492). +const CAPABILITIES: u8 = 2; + +/// An optional parameter of an OPEN. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum OpenOption { - Capabilities(Vec) = 2, + Capabilities(Vec), } impl OpenOption { - pub fn from_wire<'a>( - ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { + /// Parses one optional parameter. `extended` says whether its length is + /// two octets (RFC 9072) or one. + fn from_wire(extended: bool, buf: &[u8]) -> Parsed<'_, Self> { let (buf, type_code) = be_u8(buf)?; - let (buf, length) = be_u8(buf)?; - - let (buf, payload) = nom::bytes::take(length).parse(buf)?; + let (buf, length) = if extended { + let (buf, length) = be_u16(buf)?; + (buf, usize::from(length)) + } else { + let (buf, length) = be_u8(buf)?; + (buf, usize::from(length)) + }; + let (buf, value) = nom::bytes::take(length).parse(buf)?; match type_code { - v if v == OpenOptionDiscriminants::Capabilities as u8 => { - let (payload_consumed, (capabilities, _eof)) = nom::multi::many_till( - |buf| Capability::from_wire(ctx, buf), - nom::combinator::eof, - ) - .parse(payload)?; - - if !payload_consumed.is_empty() { - return Err(fail!( - "Leftover bytes when parsing BGP capabilities: {:x?}", - payload_consumed - )); - } + CAPABILITIES => { + let (_, capabilities) = list(value, Capability::from_wire)?; Ok((buf, Self::Capabilities(capabilities))) } - other => { - return Err(Failure(BgpParserError::ProtocolError( - ProtocolErrorValues::UnknownOpenOption(other), - ))); - } + other => Err(Failure(BgpParserError::ProtocolError( + ProtocolErrorValues::UnknownOpenOption(other), + ))), } } - pub fn to_wire(&self, ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { + fn type_code(&self) -> u8 { match self { - OpenOption::Capabilities(items) => { - let cap_len = items.iter().map(|i| i.wire_len(ctx)).sum::>()?; - if cap_len > u8::MAX as u16 { - bail!("Cannot write Capabilities with more than u8::MAX length"); - } - out.put_u8(OpenOptionDiscriminants::Capabilities as u8); - out.put_u8(cap_len as u8); - for item in items { - item.to_wire(ctx, out)?; + Self::Capabilities(_) => CAPABILITIES, + } + } + + /// The parameter's value on the wire. + fn value(&self) -> Result { + let mut value = BytesMut::new(); + match self { + Self::Capabilities(capabilities) => { + for capability in capabilities { + capability.to_wire(&mut value)?; } } } - Ok(()) - } - - pub fn wire_len(&self, ctx: &ParserContext) -> Result { - match self { - OpenOption::Capabilities(items) => items - .iter() - .map(|i| i.wire_len(ctx)) - .sum::>() - .map(|v| v + 2), - } + Ok(value) } } -#[derive(Debug, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] -#[repr(u8)] +/// One address family in a Multiple Labels capability (RFC 8277 §2.1). +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct MultiLabelEntry { + pub afi: Afi, + pub safi: Safi, + /// Maximum number of labels in one NLRI of this family. + pub count: u8, +} + +/// Capability codes (the IANA "Capability Codes" registry). +mod cap_type { + pub const MULTI_PROTOCOL: u8 = 1; + pub const ROUTE_REFRESH: u8 = 2; + pub const OUTBOUND_ROUTE_FILTER: u8 = 3; + pub const EXTENDED_NEXT_HOP: u8 = 5; + pub const EXTENDED_MESSAGE: u8 = 6; + pub const BGP_SEC: u8 = 7; + pub const MULTI_LABEL: u8 = 8; + pub const BGP_ROLE: u8 = 9; + pub const GRACEFUL_RESTART: u8 = 64; + pub const FOUR_BYTE_ASN: u8 = 65; + pub const ADD_PATH: u8 = 69; + pub const ENHANCED_ROUTE_REFRESH: u8 = 70; + pub const LONG_LIVED_GRACEFUL_RESTART: u8 = 71; +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub enum Capability { - /// MultiProtocol Extension (RFC 2858). + /// Multiprotocol Extensions (RFC 4760). MultiProtocol { - afi: AddressFamilyId, - safi: SubsequentAfi, - } = 1, + afi: Afi, + safi: Safi, + }, /// Route Refresh capability (RFC 2918). - RouteRefresh = 2, - /// Outbound Route Filtering (RFC 5291). - /// https://datatracker.ietf.org/doc/html/rfc5291 - OutboundRouteFilter { - afi: AddressFamilyId, - safi: SubsequentAfi, - orfs: Vec, - } = 3, + RouteRefresh, + /// Outbound Route Filtering (RFC 5291): for each address family, the + /// kinds of filter the speaker can send or receive. + OutboundRouteFilter(Vec), /// Extended Next Hop encoding (RFC 8950). ExtendedNextHop { entries: Vec, - } = 5, + }, /// Extended Message (RFC 8654). - ExtendedMessage = 6, + ExtendedMessage, /// BGPSec (RFC 8205). BgpSec { header: BgpSecHeader, - afi: AddressFamilyId, - } = 7, - /// Multiple labels compatibility (RFC 8277). - MultiLabelCompat {} = 8, + afi: Afi, + }, + /// Multiple labels (RFC 8277 §2.1): for each address family, how many + /// labels the speaker can handle in one NLRI. + MultiLabelCompat { + entries: Vec, + }, BgpRole { role: BgpRole, - } = 9, + }, /// Graceful restart capability (RFC 4724). GracefulRestart { - /// When set to true it means that the BGP speaker has just restarted - /// and the peer must not wait for the End of RIB marker from the - /// speaker before advertising routing information to the speaker. + /// The speaker has just restarted, and its peer must not wait for + /// its End-of-RIB marker before advertising routes to it. restart_state: bool, - /// This is the estimated time (in seconds) it will take for the - /// BGP session to be re-established after a restart. This can be - /// used to speed up routing convergence by its peer in case that - /// the BGP speaker does not come back after a restart. + /// The estimated time in seconds for the session to be + /// re-established after a restart. restart_time: u16, - /// The AFI and SAFI, taken in combination, indicate that Graceful - /// Restart is supported for routes that are advertised with the - /// same AFI and SAFI. Routes may be explicitly associated with a - /// particular AFI and SAFI using the encoding of [BGP-MP] or - /// implicitly associated with if using - /// the encoding of [BGP-4]. + /// The address families whose routes are kept over a restart. entries: Vec, - } = 64, - /// Four Byte ASN (RFC 4274). + }, + /// Four-octet AS numbers (RFC 6793). FourByteAsn { asn: u32, - } = 65, - /// Additional Path (RFC 7911). + }, + /// ADD-PATH (RFC 7911): for each address family listed, whether the + /// speaker can send several routes for a prefix, receive them, or both. AddPath { - afi: AddressFamilyId, - safi: SubsequentAfi, - send_recv: AddPathSendRecv, - } = 69, + entries: Vec, + }, /// Enhanced Route Refresh (RFC 7313). - EnhancedRouteRefresh = 70, + EnhancedRouteRefresh, /// Long Lived Graceful Restart (RFC 9494). - LongLivedGracefulRestart(Vec) = 71, - /// Unknown Capability encapsulates any capabilities that we don't know about. + LongLivedGracefulRestart(Vec), + /// A capability this implementation has no definition for, kept as it + /// came (RFC 5492 §3: what is not understood is ignored). UnknownCapability { type_code: u8, - length: u8, value: Vec, }, } +/// An AFI, a reserved octet and a SAFI: how several capabilities name an +/// address family. +fn afi_reserved_safi(buf: &[u8]) -> Parsed<'_, (Afi, Safi)> { + let (buf, afi) = be_u16(buf)?; + let (buf, _) = be_u8(buf)?; + let (buf, safi) = be_u8(buf)?; + Ok((buf, (Afi(afi), Safi(safi)))) +} + +fn afi_safi(buf: &[u8]) -> Parsed<'_, (Afi, Safi)> { + let (buf, afi) = be_u16(buf)?; + let (buf, safi) = be_u8(buf)?; + Ok((buf, (Afi(afi), Safi(safi)))) +} + impl Capability { - fn discriminant(&self) -> u8 { + fn type_code(&self) -> u8 { + use cap_type::*; match self { - Capability::MultiProtocol { .. } => CapabilityDiscriminants::MultiProtocol as u8, - Capability::RouteRefresh => CapabilityDiscriminants::RouteRefresh as u8, - Capability::OutboundRouteFilter { .. } => { - CapabilityDiscriminants::OutboundRouteFilter as u8 - } - Capability::ExtendedNextHop { .. } => CapabilityDiscriminants::ExtendedNextHop as u8, - Capability::ExtendedMessage => CapabilityDiscriminants::ExtendedMessage as u8, - Capability::BgpSec { .. } => CapabilityDiscriminants::BgpSec as u8, - Capability::MultiLabelCompat {} => CapabilityDiscriminants::MultiLabelCompat as u8, - Capability::BgpRole { .. } => CapabilityDiscriminants::BgpRole as u8, - Capability::GracefulRestart { .. } => CapabilityDiscriminants::GracefulRestart as u8, - Capability::FourByteAsn { .. } => CapabilityDiscriminants::FourByteAsn as u8, - Capability::AddPath { .. } => CapabilityDiscriminants::AddPath as u8, - Capability::EnhancedRouteRefresh => CapabilityDiscriminants::EnhancedRouteRefresh as u8, - Capability::LongLivedGracefulRestart(_) => { - CapabilityDiscriminants::LongLivedGracefulRestart as u8 - } - Capability::UnknownCapability { type_code, .. } => *type_code, + Self::MultiProtocol { .. } => MULTI_PROTOCOL, + Self::RouteRefresh => ROUTE_REFRESH, + Self::OutboundRouteFilter(_) => OUTBOUND_ROUTE_FILTER, + Self::ExtendedNextHop { .. } => EXTENDED_NEXT_HOP, + Self::ExtendedMessage => EXTENDED_MESSAGE, + Self::BgpSec { .. } => BGP_SEC, + Self::MultiLabelCompat { .. } => MULTI_LABEL, + Self::BgpRole { .. } => BGP_ROLE, + Self::GracefulRestart { .. } => GRACEFUL_RESTART, + Self::FourByteAsn { .. } => FOUR_BYTE_ASN, + Self::AddPath { .. } => ADD_PATH, + Self::EnhancedRouteRefresh => ENHANCED_ROUTE_REFRESH, + Self::LongLivedGracefulRestart(_) => LONG_LIVED_GRACEFUL_RESTART, + Self::UnknownCapability { type_code, .. } => *type_code, } } - /// `from_wire` parses a Capability message. - pub fn from_wire<'a>( - _ctx: &ParserContext, - buf: &'a [u8], - ) -> IResult<&'a [u8], Self, BgpParserError<&'a [u8]>> { - let (buf, typ) = be_u8(buf)?; - let (buf, len) = be_u8(buf)?; - let (buf, cap_buf) = nom::bytes::take(len).parse(buf)?; - macro_rules! require_empty { - ($buf:expr, $cap_type:expr) => { - if !$buf.is_empty() { - return Err(nom::Err::Failure(BgpParserError::Eyre(eyre!( - "Unexpected leftover bytes {:x?} while parsing {} capability", - $buf, - $cap_type - )))); - } - }; + + /// The Multiprotocol capability that offers `family` to a peer. + pub fn multiprotocol(family: AfiSafi) -> Self { + Self::MultiProtocol { + afi: family.afi, + safi: family.safi, } - match typ { - v if v == CapabilityDiscriminants::MultiProtocol as u8 => { - // MultiProtocol capability consists of an (AFI, SAFI). - let (cap_buf, afi) = be_u16(cap_buf)?; - let (cap_buf, _) = be_u8(cap_buf)?; - let (cap_buf, safi) = be_u8(cap_buf)?; - require_empty!(cap_buf, "MultiProtocol"); - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; - Ok((buf, Self::MultiProtocol { afi, safi })) + } + + /// Parses one capability from the front of `buf`. + /// + /// Address families are read as numbers, whatever they are: a peer is + /// free to advertise families we have never heard of, and RFC 5492 §3 + /// has us ignore what we do not support rather than refuse the OPEN. + pub fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, type_code) = be_u8(buf)?; + let (buf, length) = be_u8(buf)?; + let (buf, value) = nom::bytes::take(length).parse(buf)?; + let capability = Self::parse_value(type_code, value)?; + Ok((buf, capability)) + } + + /// Makes the capability of type `type_code` from its value. All of + /// `value` has to be used. + fn parse_value(type_code: u8, value: &[u8]) -> Result>> { + use cap_type::*; + let (rest, capability) = match type_code { + MULTI_PROTOCOL => { + let (rest, (afi, safi)) = afi_reserved_safi(value)?; + (rest, Self::MultiProtocol { afi, safi }) } - v if v == CapabilityDiscriminants::RouteRefresh as u8 => { - require_empty!(cap_buf, "RouteRefresh"); - Ok((buf, Self::RouteRefresh)) + ROUTE_REFRESH => (value, Self::RouteRefresh), + OUTBOUND_ROUTE_FILTER => { + let (rest, families) = list(value, OrfFamily::from_wire)?; + (rest, Self::OutboundRouteFilter(families)) } - v if v == CapabilityDiscriminants::OutboundRouteFilter as u8 => { - let (cap_buf, afi) = be_u16(cap_buf)?; - let (cap_buf, _) = be_u8(cap_buf)?; // Move past reverved byte. - let (cap_buf, safi) = be_u8(cap_buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; - - let (cap_buf, _num_orfs) = be_u8(cap_buf)?; - - let parse_orf = |buf: &'a [u8]| -> IResult< - &'a [u8], - OutboundRouteFilterEntry, - BgpParserError<&'a [u8]>, - > { - let (buf, orf_type) = be_u8(buf)?; - let (buf, send_recv) = be_u8(buf)?; - let orf_type = OrfType::from(orf_type); - let send_recv = OrfSendRecv::try_from(send_recv) - .map_err(|e| fail!("Failed to parse ORF send/recv: {}", e))?; - - Ok(( - buf, - OutboundRouteFilterEntry { - r#type: orf_type, - send_recv, - }, - )) - }; - - let (cap_buf, orfs) = nom::multi::many0(parse_orf).parse(cap_buf)?; - require_empty!(cap_buf, "OutboundRouteFilter"); - - Ok((buf, Self::OutboundRouteFilter { afi, safi, orfs })) - } - v if v == CapabilityDiscriminants::ExtendedNextHop as u8 => { - // parse_enh parses a single extended nexthop entry. - let parse_enh = |buf: &'a [u8]| -> IResult< - &'a [u8], - ExtendedNextHop, - BgpParserError<&'a [u8]>, - > { + EXTENDED_NEXT_HOP => { + let (rest, entries) = list(value, |buf| { let (buf, afi) = be_u16(buf)?; let (buf, safi) = be_u16(buf)?; let (buf, nh_afi) = be_u16(buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; - let nh_afi = AddressFamilyId::try_from(nh_afi).map_err(|e| fail!("{}", e))?; - Ok((buf, ExtendedNextHop { afi, safi, nh_afi })) - }; - let (cap_buf, entries) = nom::multi::many1(parse_enh).parse(cap_buf)?; - require_empty!(cap_buf, "ExtendedNextHop"); - - Ok((cap_buf, Self::ExtendedNextHop { entries })) + // Two octets on the wire here (RFC 8950), one everywhere + // else: no SAFI is larger than 255. + let safi = u8::try_from(safi) + .map(Safi) + .map_err(|_| fail!("SAFI {} does not fit in one octet", safi))?; + Ok(( + buf, + ExtendedNextHop { + afi: Afi(afi), + safi, + nh_afi: Afi(nh_afi), + }, + )) + })?; + if entries.is_empty() { + return Err(fail!("Extended Next Hop capability with no entries")); + } + (rest, Self::ExtendedNextHop { entries }) } - v if v == CapabilityDiscriminants::ExtendedMessage as u8 => { - require_empty!(cap_buf, "ExtendedMessage"); - Ok((cap_buf, Self::ExtendedMessage)) - } - v if v == CapabilityDiscriminants::BgpSec as u8 => { - let (cap_buf, header) = be_u8(cap_buf)?; - let (cap_buf, afi) = be_u16(cap_buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - require_empty!(cap_buf, "BgpSec"); - Ok(( - cap_buf, + EXTENDED_MESSAGE => (value, Self::ExtendedMessage), + BGP_SEC => { + let (rest, header) = be_u8(value)?; + let (rest, afi) = be_u16(rest)?; + ( + rest, Self::BgpSec { header: BgpSecHeader(header), - afi, + afi: Afi(afi), }, - )) + ) } - v if v == CapabilityDiscriminants::MultiLabelCompat as u8 => { - todo!() + MULTI_LABEL => { + let (rest, entries) = list(value, |buf| { + let (buf, (afi, safi)) = afi_safi(buf)?; + let (buf, count) = be_u8(buf)?; + Ok((buf, MultiLabelEntry { afi, safi, count })) + })?; + if entries.is_empty() { + return Err(fail!("Multiple Labels capability with no entries")); + } + (rest, Self::MultiLabelCompat { entries }) } - v if v == CapabilityDiscriminants::BgpRole as u8 => { - let (cap_buf, role) = be_u8(cap_buf)?; - let role = BgpRole::try_from(role).map_err(|e| fail!("{}", e))?; - require_empty!(cap_buf, "BgpRole"); - Ok((cap_buf, Self::BgpRole { role })) + BGP_ROLE => { + let (rest, role) = be_u8(value)?; + ( + rest, + Self::BgpRole { + role: BgpRole::from(role), + }, + ) } - v if v == CapabilityDiscriminants::GracefulRestart as u8 => { - // flags_restart_time is: (4 bits: restart flags, 12 bits: restart time (s)). - let (cap_buf, flags_restart_time) = be_u16(cap_buf)?; - let restart_state = ((1 << 15) & flags_restart_time) != 0; - let restart_time = flags_restart_time & 0xfff; - let parse_entry = |buf: &'a [u8]| -> IResult< - &'a [u8], - GracefulRestartEntry, - BgpParserError<&'a [u8]>, - > { - let (buf, afi) = be_u16(buf)?; - let (buf, safi) = be_u8(buf)?; + GRACEFUL_RESTART => { + // Four bits of flags and twelve of restart time. + let (rest, flags_restart_time) = be_u16(value)?; + let (rest, entries) = list(rest, |buf| { + let (buf, (afi, safi)) = afi_safi(buf)?; let (buf, flags) = be_u8(buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; Ok(( buf, GracefulRestartEntry { afi, safi, - preserve_forwarding: (flags & (1 << 7)) != 0, + preserve_forwarding: flags & (1 << 7) != 0, }, )) - }; - let (cap_buf, entries) = nom::multi::many0(parse_entry).parse(cap_buf)?; - require_empty!(cap_buf, "GracefulRestart"); - Ok(( - cap_buf, + })?; + ( + rest, Self::GracefulRestart { - restart_state, - restart_time, + restart_state: flags_restart_time & (1 << 15) != 0, + restart_time: flags_restart_time & 0xfff, entries, }, - )) + ) } - v if v == CapabilityDiscriminants::FourByteAsn as u8 => { - let (cap_buf, asn) = be_u32(cap_buf)?; - require_empty!(cap_buf, "FourByteAsn"); - Ok((buf, Self::FourByteAsn { asn })) + FOUR_BYTE_ASN => { + let (rest, asn) = be_u32(value)?; + (rest, Self::FourByteAsn { asn }) } - v if v == CapabilityDiscriminants::AddPath as u8 => { - let (cap_buf, afi) = be_u16(cap_buf)?; - let (cap_buf, safi) = be_u8(cap_buf)?; - let (cap_buf, send_recv) = be_u8(cap_buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; - let send_recv = AddPathSendRecv::try_from(send_recv).map_err(|e| fail!("{}", e))?; - require_empty!(cap_buf, "AddPath"); - Ok(( - cap_buf, - Self::AddPath { - afi, - safi, - send_recv, - }, - )) + ADD_PATH => { + // A list of families, four octets each. None at all says + // nothing and is let through: it is also what is left of a + // capability whose families were all ignored below. + let (entries, rest) = value.as_chunks::<4>(); + let entries = entries + .iter() + .filter_map(|entry| { + // A family with a Send/Receive value that is not + // defined is ignored (RFC 7911 §4). + Some(AddPathEntry { + afi: Afi(u16::from_be_bytes([entry[0], entry[1]])), + safi: Safi(entry[2]), + send_recv: AddPathSendRecv::try_from(entry[3]).ok()?, + }) + }) + .collect(); + (rest, Self::AddPath { entries }) } - v if v == CapabilityDiscriminants::EnhancedRouteRefresh as u8 => { - require_empty!(cap_buf, "EnhancedRouteRefresh"); - Ok((cap_buf, Self::EnhancedRouteRefresh)) - } - v if v == CapabilityDiscriminants::LongLivedGracefulRestart as u8 => { - let parse_llgr = |buf: &'a [u8]| -> IResult< - &'a [u8], - LongLivedGracefulRestart, - BgpParserError<&'a [u8]>, - > { - let (buf, afi) = be_u16(buf)?; - let (buf, safi) = be_u8(buf)?; + ENHANCED_ROUTE_REFRESH => (value, Self::EnhancedRouteRefresh), + LONG_LIVED_GRACEFUL_RESTART => { + let (rest, entries) = list(value, |buf| { + let (buf, (afi, safi)) = afi_safi(buf)?; let (buf, flags) = be_u8(buf)?; let (buf, stale_time) = be_u24(buf)?; - let afi = AddressFamilyId::try_from(afi).map_err(|e| fail!("{}", e))?; - let safi = SubsequentAfi::try_from(safi).map_err(|e| fail!("{}", e))?; Ok(( buf, LongLivedGracefulRestart { @@ -500,265 +493,270 @@ impl Capability { stale_time, }, )) - }; - let (cap_buf, entries) = nom::multi::many0(parse_llgr).parse(cap_buf)?; - require_empty!(cap_buf, "LongLivedGracefulRestart"); - Ok((cap_buf, Self::LongLivedGracefulRestart(entries))) - } - type_code => { - // Handling an unknown capability, so just parse it out and store the value - // as bytes. - Ok(( - &[], - Self::UnknownCapability { - type_code, - length: cap_buf.len() as u8, - value: cap_buf.to_vec(), - }, - )) + })?; + (rest, Self::LongLivedGracefulRestart(entries)) } + type_code => ( + &[][..], + Self::UnknownCapability { + type_code, + value: value.to_vec(), + }, + ), + }; + if !rest.is_empty() { + return Err(fail!( + "{} octets left over in capability {type_code}", + rest.len() + )); } + Ok(capability) } - pub fn to_wire(&self, _ctx: &ParserContext, out: &mut BytesMut) -> Result<()> { - out.put_u8(self.discriminant()); - out.put_u8(self.payload_len()); + /// Writes the capability, with its type and length, to the end of + /// `out`. + pub fn to_wire(&self, out: &mut BytesMut) -> Result<(), EncodeError> { + out.put_u8(self.type_code()); + // The length, filled in once the value is written. + let length_at = out.len(); + out.put_u8(0); + self.value_to_wire(out)?; + let length = out.len() - length_at - 1; + out[length_at] = u8::try_from(length).map_err(|_| EncodeError::TooLong { + what: "capability", + len: length, + max: u8::MAX.into(), + })?; + Ok(()) + } + + fn value_to_wire(&self, out: &mut BytesMut) -> Result<(), EncodeError> { match self { Capability::MultiProtocol { afi, safi } => { - out.put_u16(*afi as u16); - out.put_u8(0); // Reserved byte. - out.put_u8(*safi as u8); + out.put_u16(afi.0); + out.put_u8(0); // Reserved. + out.put_u8(safi.0); } - Capability::RouteRefresh => {} - Capability::OutboundRouteFilter { afi, safi, orfs } => { - out.put_u16(*afi as u16); - out.put_u8(0); // Reserved byte. - out.put_u8(*safi as u8); - for orf in orfs { - out.put_u8(orf.r#type.clone().into()); - out.put_u8(orf.send_recv.clone() as u8); + Capability::RouteRefresh + | Capability::ExtendedMessage + | Capability::EnhancedRouteRefresh => {} + Capability::OutboundRouteFilter(families) => { + for family in families { + family.to_wire(out)?; } } Capability::ExtendedNextHop { entries } => { for entry in entries { - out.put_u16(entry.afi as u16); - out.put_u16(entry.safi as u16); - out.put_u16(entry.nh_afi as u16); + out.put_u16(entry.afi.0); + out.put_u16(u16::from(entry.safi.0)); + out.put_u16(entry.nh_afi.0); } } - Capability::ExtendedMessage => {} Capability::BgpSec { header, afi } => { out.put_u8(header.0); - out.put_u16(*afi as u16); + out.put_u16(afi.0); } - Capability::MultiLabelCompat {} => todo!(), - Capability::BgpRole { role } => { - out.put_u8(role.into()); + Capability::MultiLabelCompat { entries } => { + for entry in entries { + out.put_u16(entry.afi.0); + out.put_u8(entry.safi.0); + out.put_u8(entry.count); + } } + Capability::BgpRole { role } => out.put_u8(role.into()), Capability::GracefulRestart { restart_state, restart_time, entries, } => { - let mut flags_restart_time = 0; - if *restart_state { - flags_restart_time |= 1 << 15; - } if *restart_time > 0xfff { - bail!("Restart time too large to be encoded: {}", restart_time); + return Err(EncodeError::TooLong { + what: "graceful restart time", + len: (*restart_time).into(), + max: 0xfff, + }); } - flags_restart_time |= restart_time; - out.put_u16(flags_restart_time); + out.put_u16(u16::from(*restart_state) << 15 | restart_time); for entry in entries { - out.put_u16(entry.afi as u16); - out.put_u8(entry.safi as u8); - let mut flags = 0; - if entry.preserve_forwarding { - flags |= 1 << 7; - } - out.put_u8(flags); + out.put_u16(entry.afi.0); + out.put_u8(entry.safi.0); + out.put_u8(u8::from(entry.preserve_forwarding) << 7); } } - Capability::FourByteAsn { asn } => { - out.put_u32(*asn); - } - Capability::AddPath { - afi, - safi, - send_recv, - } => { - out.put_u16(*afi as u16); - out.put_u8(*safi as u8); - out.put_u8(send_recv.into()); - } - Capability::EnhancedRouteRefresh => {} - Capability::LongLivedGracefulRestart(llgrs) => { - for llgr in llgrs { - out.put_u16(llgr.afi as u16); - out.put_u8(llgr.safi as u8); - out.put_u8(llgr.flags as u8); - out.put(&llgr.stale_time.to_be_bytes()[1..]); + Capability::FourByteAsn { asn } => out.put_u32(*asn), + Capability::AddPath { entries } => { + for entry in entries { + out.put_u16(entry.afi.0); + out.put_u8(entry.safi.0); + out.put_u8(entry.send_recv as u8); } } - Capability::UnknownCapability { value, .. } => out.put(value.as_slice()), + Capability::LongLivedGracefulRestart(entries) => { + for entry in entries { + out.put_u16(entry.afi.0); + out.put_u8(entry.safi.0); + out.put_u8(entry.flags); + out.put_slice(&entry.stale_time.to_be_bytes()[1..]); + } + } + Capability::UnknownCapability { value, .. } => out.put_slice(value), } Ok(()) } +} - pub fn wire_len(&self, _ctx: &ParserContext) -> Result { - Ok(2 + self.payload_len() as u16) +/// One address family in an Outbound Route Filtering capability, and the +/// kinds of filter offered for it (RFC 5291 §4). +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct OrfFamily { + pub afi: Afi, + pub safi: Safi, + pub orfs: Vec, +} + +impl OrfFamily { + fn from_wire(buf: &[u8]) -> Parsed<'_, Self> { + let (buf, (afi, safi)) = afi_reserved_safi(buf)?; + let (mut buf, count) = be_u8(buf)?; + let mut orfs = Vec::with_capacity(count.into()); + for _ in 0..count { + let (rest, orf_type) = be_u8(buf)?; + let (rest, send_recv) = be_u8(rest)?; + let send_recv = OrfSendRecv::try_from(send_recv) + .map_err(|e| fail!("Failed to parse ORF send/recv: {}", e))?; + orfs.push(OutboundRouteFilterEntry { + r#type: OrfType(orf_type), + send_recv, + }); + buf = rest; + } + Ok((buf, Self { afi, safi, orfs })) } - fn payload_len(&self) -> u8 { - match self { - Capability::MultiProtocol { .. } => 4, - Capability::RouteRefresh => 0, - Capability::OutboundRouteFilter { orfs, .. } => 4 + 2 * orfs.len() as u8, - Capability::ExtendedNextHop { entries } => 6 * entries.len() as u8, - Capability::ExtendedMessage => 0, - Capability::BgpSec { .. } => 3, - Capability::MultiLabelCompat {} => todo!(), - Capability::BgpRole { .. } => 1, - Capability::GracefulRestart { entries, .. } => 2 + 4 * entries.len() as u8, - Capability::FourByteAsn { .. } => 4, - Capability::AddPath { .. } => 4, - Capability::EnhancedRouteRefresh => 0, - Capability::LongLivedGracefulRestart(long_lived_graceful_restarts) => { - 8 * long_lived_graceful_restarts.len() as u8 - } - Capability::UnknownCapability { value, .. } => value.len() as u8, + fn to_wire(&self, out: &mut BytesMut) -> Result<(), EncodeError> { + out.put_u16(self.afi.0); + out.put_u8(0); // Reserved. + out.put_u8(self.safi.0); + let count = u8::try_from(self.orfs.len()).map_err(|_| EncodeError::TooLong { + what: "ORF types of one address family", + len: self.orfs.len(), + max: u8::MAX.into(), + })?; + out.put_u8(count); + for orf in &self.orfs { + out.put_u8(orf.r#type.0); + out.put_u8(orf.send_recv as u8); } + Ok(()) } } -/// Represents an entry for the OutboundRouteFilter capability. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +/// One kind of filter in an Outbound Route Filtering capability. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct OutboundRouteFilterEntry { pub r#type: OrfType, pub send_recv: OrfSendRecv, } -#[derive(Debug, Clone, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] +/// An ORF type. Any number is one: most are unassigned or vendor-specific. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(transparent)] +pub struct OrfType(pub u8); + +impl OrfType { + pub const ADDRESS_PREFIX: Self = Self(64); + pub const COVERING_PREFIX: Self = Self(65); + pub const VPN_PREFIX: Self = Self(66); +} + +/// Whether a speaker sends, receives, or does both, in a capability that +/// is offered for each direction apart: ORF (RFC 5291) and ADD-PATH +/// (RFC 7911) use the same three values. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] #[repr(u8)] -pub enum OrfType { - Reserved = 0, - Unassigned(u8), - AddressPrefix = 64, - CoveringPrefix = 65, - VpnPrefix = 66, - VendorSpecific(u8), -} - -impl From for OrfType { - fn from(value: u8) -> Self { - match value { - v if v == OrfTypeDiscriminants::Reserved as u8 => Self::Reserved, - v if (1..64).contains(&v) => Self::Unassigned(v), - v if v == OrfTypeDiscriminants::AddressPrefix as u8 => Self::AddressPrefix, - v if v == OrfTypeDiscriminants::CoveringPrefix as u8 => Self::CoveringPrefix, - v if v == OrfTypeDiscriminants::VpnPrefix as u8 => Self::VpnPrefix, - other => Self::VendorSpecific(other), - } - } -} - -impl From for u8 { - fn from(value: OrfType) -> Self { - match value { - OrfType::Reserved => OrfTypeDiscriminants::Reserved as u8, - OrfType::Unassigned(inner) => inner, - OrfType::AddressPrefix => OrfTypeDiscriminants::AddressPrefix as u8, - OrfType::CoveringPrefix => OrfTypeDiscriminants::CoveringPrefix as u8, - OrfType::VpnPrefix => OrfTypeDiscriminants::VpnPrefix as u8, - OrfType::VendorSpecific(inner) => inner, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] -#[repr(u8)] -pub enum OrfSendRecv { +pub enum SendReceive { Receive = 1, Send = 2, Both = 3, } -impl TryFrom for OrfSendRecv { - type Error = eyre::ErrReport; +pub type OrfSendRecv = SendReceive; +pub type AddPathSendRecv = SendReceive; - fn try_from(value: u8) -> Result { - Ok(match value { - value if value == OrfSendRecv::Receive as u8 => OrfSendRecv::Receive, - value if value == OrfSendRecv::Send as u8 => OrfSendRecv::Send, - value if value == OrfSendRecv::Both as u8 => OrfSendRecv::Both, - other => bail!("Invalid ORF send/recv value: {}", other), - }) +impl SendReceive { + /// Whether the speaker that advertised this sends. + pub fn sends(self) -> bool { + matches!(self, Self::Send | Self::Both) + } + + /// Whether the speaker that advertised this receives. + pub fn receives(self) -> bool { + matches!(self, Self::Receive | Self::Both) } } -#[derive(Debug, Clone, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] -#[repr(u8)] -pub enum AddPathSendRecv { - Receive = 1, - Send = 2, - Both = 3, -} +impl TryFrom for SendReceive { + type Error = InvalidValue; -impl TryFrom for AddPathSendRecv { - type Error = eyre::ErrReport; - - fn try_from(value: u8) -> Result { - Ok(match value { - value if value == AddPathSendRecv::Receive as u8 => AddPathSendRecv::Receive, - value if value == AddPathSendRecv::Send as u8 => AddPathSendRecv::Send, - value if value == AddPathSendRecv::Both as u8 => AddPathSendRecv::Both, - other => bail!("Invalid AddPathSendRecv send/recv value: {}", other), - }) - } -} - -impl From<&AddPathSendRecv> for u8 { - fn from(value: &AddPathSendRecv) -> Self { + fn try_from(value: u8) -> Result { match value { - AddPathSendRecv::Receive => AddPathSendRecv::Receive as u8, - AddPathSendRecv::Send => AddPathSendRecv::Send as u8, - AddPathSendRecv::Both => AddPathSendRecv::Both as u8, + 1 => Ok(Self::Receive), + 2 => Ok(Self::Send), + 3 => Ok(Self::Both), + other => Err(InvalidValue { + what: "send/receive", + value: other.into(), + }), } } } +/// One address family in an ADD-PATH capability. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AddPathEntry { + pub afi: Afi, + pub safi: Safi, + pub send_recv: AddPathSendRecv, +} + +impl AddPathEntry { + pub fn family(&self) -> AfiSafi { + AfiSafi::new(self.afi, self.safi) + } +} + /// `ExtendedNextHop` allows for the nexthop to be for a different address family /// than the one being used to advertise prefixes for. E.g. it allows for sending /// IPv4 prefixes with an IPv6 nexthop. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct ExtendedNextHop { /// The Address Family to update the nexthop format for. - pub afi: AddressFamilyId, + pub afi: Afi, /// The Subsequent Address Family to update the nexthop format for. - pub safi: SubsequentAfi, + pub safi: Safi, /// The Address Family that nexthops will be encoded as. - pub nh_afi: AddressFamilyId, + pub nh_afi: Afi, } -#[derive(Debug, Clone, Serialize, Deserialize, EnumDiscriminants, PartialEq, Eq)] -#[repr(u8)] +/// The role a speaker has towards its peer (RFC 9234 §4). +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] pub enum BgpRole { - Provider = 0, - RouteServer = 1, - RouteServerClient = 2, - Customer = 3, - Peer = 4, + Provider, + RouteServer, + RouteServerClient, + Customer, + Peer, Unknown(u8), } impl From for BgpRole { fn from(value: u8) -> Self { match value { - v if v == BgpRoleDiscriminants::Provider as u8 => Self::Provider, - v if v == BgpRoleDiscriminants::RouteServer as u8 => Self::RouteServer, - v if v == BgpRoleDiscriminants::RouteServerClient as u8 => Self::RouteServerClient, - v if v == BgpRoleDiscriminants::Customer as u8 => Self::Customer, - v if v == BgpRoleDiscriminants::Peer as u8 => Self::Peer, + 0 => Self::Provider, + 1 => Self::RouteServer, + 2 => Self::RouteServerClient, + 3 => Self::Customer, + 4 => Self::Peer, other => Self::Unknown(other), } } @@ -767,20 +765,20 @@ impl From for BgpRole { impl From<&BgpRole> for u8 { fn from(value: &BgpRole) -> Self { match value { - BgpRole::Provider => BgpRoleDiscriminants::Provider as u8, - BgpRole::RouteServer => BgpRoleDiscriminants::RouteServer as u8, - BgpRole::RouteServerClient => BgpRoleDiscriminants::RouteServerClient as u8, - BgpRole::Customer => BgpRoleDiscriminants::Customer as u8, - BgpRole::Peer => BgpRoleDiscriminants::Peer as u8, + BgpRole::Provider => 0, + BgpRole::RouteServer => 1, + BgpRole::RouteServerClient => 2, + BgpRole::Customer => 3, + BgpRole::Peer => 4, BgpRole::Unknown(other) => *other, } } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct LongLivedGracefulRestart { - pub afi: AddressFamilyId, - pub safi: SubsequentAfi, + pub afi: Afi, + pub safi: Safi, pub flags: u8, /// stale_time is the time in seconds after which routes are considered stale. /// Note that this is represented as a u24 on the wire. @@ -788,7 +786,7 @@ pub struct LongLivedGracefulRestart { } bitfield! { - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] + #[derive(Clone, Debug, Serialize, PartialEq, Eq)] pub struct BgpSecHeader(u8); impl new; u8, version, set_version : 3, 0; // 4 bits @@ -797,14 +795,14 @@ bitfield! { } /// Represents an entry in the BGP Graceful Restart capability. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct GracefulRestartEntry { - afi: AddressFamilyId, - safi: SubsequentAfi, + pub afi: Afi, + pub safi: Safi, /// Forwarding State (F) bit, which can be used to indicate whether the /// forwarding state for routes that were advertised with the given AFI /// and SAFI has indeed been preserved during the previous BGP restart. - preserve_forwarding: bool, + pub preserve_forwarding: bool, } #[cfg(test)] @@ -814,43 +812,35 @@ mod tests { use bytes::BytesMut; use eyre::Result; - use crate::open::{Capability, ExtendedNextHop, GracefulRestartEntry, OpenMessage, OpenOption}; - - use bgp_primitives::{ - constants::{AddressFamilyId, SubsequentAfi}, - parser::ParserContext, + use crate::open::{ + AddPathEntry, AddPathSendRecv, Capability, ExtendedNextHop, GracefulRestartEntry, + MultiLabelEntry, OpenMessage, OpenOption, OrfFamily, OrfSendRecv, OrfType, + OutboundRouteFilterEntry, }; + use bgp_primitives::constants::{Afi, Safi}; + macro_rules! test_capability_roundtrip { ($name:ident, $input_bytes: expr, $expected: expr) => { #[test] fn $name() -> Result<()> { - let ctx = &default_v6_context(); - let (buf, parsed) = Capability::from_wire(ctx, $input_bytes)?; + let (buf, parsed) = Capability::from_wire($input_bytes)?; assert_eq!(parsed, $expected); assert!(buf.is_empty()); let mut out = BytesMut::with_capacity(u16::MAX as usize); - parsed.to_wire(ctx, &mut out)?; + parsed.to_wire(&mut out)?; assert_eq!(out.to_vec(), $input_bytes); Ok(()) } }; } - /// Creates a default context for evaluating the test cases below. - /// This uses `four_octet_asn` set to true and address_family to IPv6. - fn default_v6_context() -> ParserContext { - ParserContext { - four_octet_asn: Some(true), - address_family: Some(AddressFamilyId::Ipv6), - } - } test_capability_roundtrip!( test_multiptotocol, &[0x01, 0x04, 0x00, 0x01, 0x00, 0x01], Capability::MultiProtocol { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast + afi: Afi::IPV4, + safi: Safi::UNICAST } ); @@ -861,7 +851,6 @@ mod tests { &[0x80, 0x00], Capability::UnknownCapability { type_code: 0x80, - length: 0x00, value: vec![] } ); @@ -879,8 +868,8 @@ mod tests { restart_state: false, restart_time: 300, entries: vec![GracefulRestartEntry { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, + afi: Afi::IPV4, + safi: Safi::UNICAST, preserve_forwarding: false, }] } @@ -895,19 +884,19 @@ mod tests { Capability::ExtendedNextHop { entries: vec![ ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, - nh_afi: AddressFamilyId::Ipv6, + afi: Afi::IPV4, + safi: Safi::UNICAST, + nh_afi: Afi::IPV6, }, ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Multicast, - nh_afi: AddressFamilyId::Ipv6, + afi: Afi::IPV4, + safi: Safi::MULTICAST, + nh_afi: Afi::IPV6, }, ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::MplsLabelledVpn, - nh_afi: AddressFamilyId::Ipv6 + afi: Afi::IPV4, + safi: Safi::MPLS_VPN, + nh_afi: Afi::IPV6 } ] } @@ -929,12 +918,11 @@ mod tests { identifier: Ipv4Addr::new(10, 0, 0, 1), options: vec![ OpenOption::Capabilities(vec![Capability::MultiProtocol { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, + afi: Afi::IPV4, + safi: Safi::UNICAST, }]), OpenOption::Capabilities(vec![Capability::UnknownCapability { type_code: 0x80, - length: 0x00, value: vec![], }]), OpenOption::Capabilities(vec![Capability::RouteRefresh]), @@ -943,39 +931,356 @@ mod tests { restart_state: false, restart_time: 300, entries: vec![GracefulRestartEntry { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, + afi: Afi::IPV4, + safi: Safi::UNICAST, preserve_forwarding: false, }], }]), OpenOption::Capabilities(vec![Capability::ExtendedNextHop { entries: vec![ ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, - nh_afi: AddressFamilyId::Ipv6, + afi: Afi::IPV4, + safi: Safi::UNICAST, + nh_afi: Afi::IPV6, }, ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Multicast, - nh_afi: AddressFamilyId::Ipv6, + afi: Afi::IPV4, + safi: Safi::MULTICAST, + nh_afi: Afi::IPV6, }, ExtendedNextHop { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::MplsLabelledVpn, - nh_afi: AddressFamilyId::Ipv6, + afi: Afi::IPV4, + safi: Safi::MPLS_VPN, + nh_afi: Afi::IPV6, }, ], }]), ], }; - let ctx = default_v6_context(); - let (_, parsed) = OpenMessage::from_wire(&ctx, buf)?; + let (_, parsed) = OpenMessage::from_wire(buf)?; assert_eq!(parsed, expected); let mut out = BytesMut::with_capacity(u16::MAX as usize); - parsed.to_wire(&ctx, &mut out)?; + parsed.to_wire(&mut out)?; assert_eq!(buf.to_vec(), out.to_vec()); Ok(()) } + + /// A peer advertising Multiple Labels used to hit a `todo!()` and take + /// the session task down with it. + #[test] + fn multiple_labels_capability_round_trips() { + // code 8, length 8: (IPv4, labelled unicast, 2) and (IPv6, labelled unicast, 3) + let wire = [0x08, 0x08, 0x00, 0x01, 0x04, 0x02, 0x00, 0x02, 0x04, 0x03]; + let (rest, cap) = Capability::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + cap, + Capability::MultiLabelCompat { + entries: vec![ + MultiLabelEntry { + afi: Afi::IPV4, + safi: Safi::MPLS_LABEL, + count: 2 + }, + MultiLabelEntry { + afi: Afi::IPV6, + safi: Safi::MPLS_LABEL, + count: 3 + }, + ] + } + ); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], &wire[..]); + + // A truncated entry is a parse error, not a panic. + assert!(Capability::from_wire(&[0x08, 0x03, 0x00, 0x01, 0x04]).is_err()); + } + + /// A peer may offer families this implementation does not support + /// (here L2VPN EVPN, AFI 25 SAFI 70). That used to fail the whole OPEN. + /// The family is read as the numbers it is, and what follows it too. + #[test] + fn a_capability_may_name_any_address_family() { + let evpn = (Afi::L2VPN, Safi::EVPN); + let samples: &[(&[u8], Capability)] = &[ + ( + &[0x01, 0x04, 0x00, 0x19, 0x00, 0x46], + Capability::MultiProtocol { + afi: evpn.0, + safi: evpn.1, + }, + ), + ( + &[0x45, 0x04, 0x00, 0x19, 0x46, 0x03], + Capability::AddPath { + entries: vec![AddPathEntry { + afi: evpn.0, + safi: evpn.1, + send_recv: AddPathSendRecv::Both, + }], + }, + ), + ( + &[0x40, 0x06, 0x00, 0x78, 0x00, 0x19, 0x46, 0x80], + Capability::GracefulRestart { + restart_state: false, + restart_time: 120, + entries: vec![GracefulRestartEntry { + afi: evpn.0, + safi: evpn.1, + preserve_forwarding: true, + }], + }, + ), + ]; + for (wire, expected) in samples { + let mut input = wire.to_vec(); + input.extend_from_slice(&[0x02, 0x00]); + let (rest, cap) = Capability::from_wire(&input).unwrap(); + assert_eq!(rest, [0x02, 0x00], "{expected:?}"); + assert_eq!(cap, *expected); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], *wire, "{expected:?}"); + } + + // A capability that is simply malformed is still an error. + assert!(Capability::from_wire(&[0x01, 0x03, 0x00, 0x01, 0x00]).is_err()); + } + + /// One ADD-PATH capability lists every family it is for. Reading only + /// one made the OPEN of a router with ADD-PATH for both IPv4 and IPv6 + /// malformed, so the session never came up. + #[test] + fn add_path_capability_lists_several_families() { + // IPv4 unicast: receive. IPv6 unicast: send and receive. + let wire = [0x45, 0x08, 0x00, 0x01, 0x01, 0x01, 0x00, 0x02, 0x01, 0x03]; + let (rest, cap) = Capability::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + cap, + Capability::AddPath { + entries: vec![ + AddPathEntry { + afi: Afi::IPV4, + safi: Safi::UNICAST, + send_recv: AddPathSendRecv::Receive, + }, + AddPathEntry { + afi: Afi::IPV6, + safi: Safi::UNICAST, + send_recv: AddPathSendRecv::Both, + }, + ], + } + ); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], &wire[..]); + + // A family with a Send/Receive value nobody defined is left out; + // the others stand. + let odd = [0x45, 0x08, 0x00, 0x01, 0x01, 0x07, 0x00, 0x02, 0x01, 0x02]; + let (_, cap) = Capability::from_wire(&odd).unwrap(); + assert!( + matches!(&cap, Capability::AddPath { entries } if entries.len() == 1 && entries[0].afi == Afi::IPV6) + ); + // Not a whole number of families: malformed. + assert!(Capability::from_wire(&[0x45, 0x05, 0, 1, 1, 1, 0]).is_err()); + } + + // Found by the property tests in tests/properties.rs: both were written + // differently from how they are read, so an OPEN of ours carrying one + // would have been malformed. + + #[test] + fn outbound_route_filter_capability_is_written_with_its_count() { + // IPv4 unicast, one ORF: address prefix (64), both directions. + let wire = [0x03, 0x07, 0x00, 0x01, 0x00, 0x01, 0x01, 0x40, 0x03]; + let (rest, cap) = Capability::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], &wire[..]); + } + + /// One ORF capability may list several address families, each with its + /// own count of ORF types (RFC 5291 §4). The second family used to be + /// read as more ORF types of the first, which made the OPEN of a router + /// offering prefix filters for IPv4 and IPv6 malformed. + #[test] + fn outbound_route_filter_capability_lists_several_families() { + #[rustfmt::skip] + let wire = [ + 0x03, 0x0e, + 0x00, 0x01, 0x00, 0x01, 0x01, 0x40, 0x03, // IPv4 unicast: prefix, both + 0x00, 0x02, 0x00, 0x01, 0x01, 0x40, 0x01, // IPv6 unicast: prefix, receive + ]; + let (rest, cap) = Capability::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + let family = |afi, send_recv| OrfFamily { + afi, + safi: Safi::UNICAST, + orfs: vec![OutboundRouteFilterEntry { + r#type: OrfType::ADDRESS_PREFIX, + send_recv, + }], + }; + assert_eq!( + cap, + Capability::OutboundRouteFilter(vec![ + family(Afi::IPV4, OrfSendRecv::Both), + family(Afi::IPV6, OrfSendRecv::Receive), + ]) + ); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], &wire[..]); + + // A count that promises more ORF types than there are. + assert!(Capability::from_wire(&[0x03, 0x05, 0x00, 0x01, 0x00, 0x01, 0x01]).is_err()); + } + + /// Optional parameters longer than 255 octets use the lengths of + /// RFC 9072: two octets for the total and for each parameter. + #[test] + fn long_optional_parameters_use_two_octet_lengths() { + let capabilities: Vec = (0..60) + .map(|i| Capability::UnknownCapability { + type_code: 200, + value: vec![i; 4], + }) + .collect(); + let open = OpenMessage { + version: 4, + asn: 65000, + hold_time: 90, + identifier: Ipv4Addr::new(10, 0, 0, 1), + options: vec![OpenOption::Capabilities(capabilities)], + }; + let mut out = BytesMut::new(); + open.to_wire(&mut out).unwrap(); + // 60 capabilities of 6 octets in one parameter. + #[rustfmt::skip] + assert_eq!( + &out[9..16], + [ + 255, 255, 0x01, 0x6b, // extended: 363 octets of parameters + 2, 0x01, 0x68, // capabilities, 360 octets + ] + ); + assert_eq!(out.len(), 16 + 360); + let (rest, parsed) = OpenMessage::from_wire(&out).unwrap(); + assert!(rest.is_empty()); + assert_eq!(parsed, open); + + // The same form is read when it was not needed. + #[rustfmt::skip] + let short = [ + 4, 0xfd, 0xe8, 0, 90, 10, 0, 0, 1, + 255, 255, 0, 5, + 2, 0, 2, 0x02, 0x00, + ]; + let (rest, parsed) = OpenMessage::from_wire(&short).unwrap(); + assert!(rest.is_empty()); + assert_eq!( + parsed.capabilities().collect::>(), + [&Capability::RouteRefresh] + ); + } + + #[test] + fn long_lived_graceful_restart_entries_are_seven_octets() { + // IPv4 unicast, no flags, stale time 3600 s. + let wire = [0x47, 0x07, 0x00, 0x01, 0x01, 0x00, 0x00, 0x0e, 0x10]; + let (rest, cap) = Capability::from_wire(&wire).unwrap(); + assert!(rest.is_empty()); + let mut out = BytesMut::new(); + cap.to_wire(&mut out).unwrap(); + assert_eq!(&out[..], &wire[..]); + } + + /// A capability's value has a one-octet length. One that does not fit + /// used to be written with the length wrapped around. + #[test] + fn a_capability_too_long_for_its_length_octet_is_refused() { + let entry = ExtendedNextHop { + afi: Afi::IPV4, + safi: Safi::UNICAST, + nh_afi: Afi::IPV6, + }; + let fits = Capability::ExtendedNextHop { + entries: vec![entry.clone(); 42], + }; + let mut out = BytesMut::new(); + fits.to_wire(&mut out).unwrap(); + assert_eq!(out.len(), 2 + 252); + + let too_long = Capability::ExtendedNextHop { + entries: vec![entry; 43], + }; + assert!(too_long.to_wire(&mut BytesMut::new()).is_err()); + } + + /// Each capability must leave the bytes that follow it for the caller. + /// Returning the (empty) per-capability buffer instead makes the list + /// parser stop early and silently lose every later capability, e.g. the + /// 4-octet ASN that routers list after graceful restart. + #[test] + fn capability_parsers_leave_the_following_bytes() { + let trailer: &[u8] = &[0x41, 0x04, 0x00, 0x03, 0x34, 0x74]; + let samples: &[(&str, &[u8])] = &[ + ("multiprotocol", &[0x01, 0x04, 0x00, 0x02, 0x00, 0x01]), + ("route refresh", &[0x02, 0x00]), + ( + "extended next hop", + &[0x05, 0x06, 0x00, 0x01, 0x00, 0x01, 0x00, 0x02], + ), + ("extended message", &[0x06, 0x00]), + ("role", &[0x09, 0x01, 0x00]), + ("graceful restart, aware only", &[0x40, 0x02, 0x00, 0x00]), + ( + "graceful restart, one family", + &[0x40, 0x06, 0x00, 0x78, 0x00, 0x02, 0x01, 0x80], + ), + ("4-octet asn", &[0x41, 0x04, 0x00, 0x00, 0xfd, 0xe8]), + ("add-path", &[0x45, 0x04, 0x00, 0x02, 0x01, 0x03]), + ("enhanced route refresh", &[0x46, 0x00]), + ("long-lived graceful restart, none", &[0x47, 0x00]), + ("unknown (fqdn)", &[0x49, 0x02, b'a', b'b']), + ]; + for (name, bytes) in samples { + let mut input = bytes.to_vec(); + input.extend_from_slice(trailer); + let (rest, _) = Capability::from_wire(&input) + .unwrap_or_else(|e| panic!("{name}: failed to parse: {e:?}")); + assert_eq!( + rest, trailer, + "{name}: did not return the bytes following the capability" + ); + } + } + + /// The capability list of a BIRD 2 OPEN, end to end. + #[test] + fn bird_capability_list_is_parsed_completely() { + #[rustfmt::skip] + let param: &[u8] = &[ + 0x02, 0x18, + 0x01, 0x04, 0x00, 0x02, 0x00, 0x01, + 0x02, 0x00, + 0x06, 0x00, + 0x40, 0x02, 0x00, 0x00, + 0x41, 0x04, 0x00, 0x03, 0x34, 0x74, + 0x46, 0x00, + 0x47, 0x00, + ]; + let (rest, OpenOption::Capabilities(caps)) = OpenOption::from_wire(false, param).unwrap(); + assert!(rest.is_empty()); + assert_eq!(caps.len(), 7, "{caps:?}"); + assert!(caps.contains(&Capability::FourByteAsn { asn: 210036 })); + } } diff --git a/crates/packet/src/update_decode.rs b/crates/packet/src/update_decode.rs new file mode 100644 index 0000000..259bc2b --- /dev/null +++ b/crates/packet/src/update_decode.rs @@ -0,0 +1,1008 @@ +//! Decoding of UPDATE messages with the error handling of RFC 7606. +//! +//! RFC 4271 answered every malformed UPDATE by resetting the session, which +//! withdraws every route learned over it. One bad attribute, often +//! originated by a router several ASes away and passed on faithfully by a +//! peer that did not look at it, could take down a session carrying a full +//! table. RFC 7606 keeps the session where that is safe: +//! +//! - **Treat-as-withdraw.** The routes the UPDATE announces are handled as +//! if it had withdrawn them. Wrong attributes must not be used, and +//! keeping the routes' previous attributes would be wrong too. +//! - **Attribute discard.** The attribute is dropped and the routes are +//! kept, for the few attributes that have no effect on route selection. +//! - **Session reset** remains for errors that leave the prefixes +//! themselves in doubt: if we cannot tell which routes a message is +//! about, we cannot withdraw them. +//! +//! [`UpdateMessage::decode`] applies this. It returns the UPDATE as it is to +//! be acted on, with treat-as-withdraw and attribute discard already carried +//! out, together with a report of what was wrong, for the log. + +use bgp_primitives::constants::{AfiSafi, IpVersion}; +use bgp_primitives::ip_prefix::Nlri; +use bgp_primitives::parser::ParserContext; + +use crate::errors::{BgpError, DecodeError, UpdateMsgSubcode}; +use crate::message::attr_type::*; +use crate::message::{ + MpUnreachNlriPathAttribute, NotAnAttribute, PathAttribute, RawAttribute, UpdateMessage, + defined_flags, describe, parse_nlri_list, split_attribute, +}; + +/// How an error in an UPDATE was handled, short of resetting the session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Handling { + /// The routes the UPDATE announced were withdrawn instead. + TreatAsWithdraw, + /// The attribute was dropped; the routes were kept. + AttributeDiscard, +} + +impl Handling { + pub fn as_str(self) -> &'static str { + match self { + Self::TreatAsWithdraw => "treat-as-withdraw", + Self::AttributeDiscard => "attribute-discard", + } + } +} + +/// Something wrong in an UPDATE that the session survived. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpdateError { + pub handling: Handling, + /// Type code of the attribute at fault, when it is one attribute. + pub attribute: Option, + pub detail: String, +} + +/// An UPDATE as it is to be acted on, and what was wrong with it as sent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedUpdate { + /// The UPDATE after treat-as-withdraw and attribute discard. + pub update: UpdateMessage, + /// Empty when the UPDATE was well formed. + pub errors: Vec, + /// The attributes in `update`, as they were on the wire, for a table + /// that keeps attributes in that form and need not write them out + /// again. MP_REACH_NLRI and MP_UNREACH_NLRI are left out, since they + /// name prefixes, and so is every attribute that was discarded. + /// + /// `None` when the UPDATE has no such attributes, when its routes were + /// withdrawn because of an error, or when the bytes are not in the one + /// form such a table keeps: on a session with two-octet AS numbers. + pub attributes_wire: Option>, +} + +/// How a malformed value of the attribute with this type code is handled +/// (RFC 7606 §7, and RFC 6793 §6 for the AS4 attributes). `None` means the +/// session is reset: an error inside MP_REACH_NLRI or MP_UNREACH_NLRI leaves +/// it unknown which routes the UPDATE is about. +fn handling_of_malformed(type_code: u8) -> Option { + match type_code { + MP_REACH_NLRI | MP_UNREACH_NLRI => None, + // These do not affect which route is selected. + ATOMIC_AGGREGATE | AGGREGATOR | AS4_PATH | AS4_AGGREGATOR => { + Some(Handling::AttributeDiscard) + } + _ => Some(Handling::TreatAsWithdraw), + } +} + +fn reset(subcode: UpdateMsgSubcode, data: Vec, detail: String) -> DecodeError { + DecodeError { + notification: BgpError::UpdateMsg(subcode), + data, + detail, + } +} + +/// Parses a field of the message body that is nothing but prefixes. An +/// error here cannot be recovered from: the prefixes are what +/// treat-as-withdraw would act on. +fn prefixes(ctx: &ParserContext, field: &str, buf: &[u8]) -> Result, DecodeError> { + // The prefixes in the body are IPv4 unicast (RFC 4271 §4.3). + let with_path_id = ctx.has_add_path(AfiSafi::IPV4_UNICAST); + match parse_nlri_list(IpVersion::Ipv4, with_path_id, buf) { + Ok((_, prefixes)) => Ok(prefixes), + Err(e) => Err(reset( + UpdateMsgSubcode::InvalidNetworkField, + vec![], + format!("malformed {field}: {}", describe(&e)), + )), + } +} + +/// Splits off the field at the front of `buf` that begins with its own +/// two-octet length: the field, and what follows it. The two such fields +/// frame an UPDATE; if they do not fit, there is no telling where anything +/// is (RFC 7606 §3.e). +fn length_prefixed<'a>(buf: &'a [u8], what: &str) -> Result<(&'a [u8], &'a [u8]), DecodeError> { + let framing = |detail: String| { + reset( + UpdateMsgSubcode::MalformedAttrs, + vec![], + format!("malformed UPDATE: {detail}"), + ) + }; + let Some((length, rest)) = buf.split_first_chunk::<2>() else { + return Err(framing(format!("no room for the {what} length"))); + }; + let length = usize::from(u16::from_be_bytes(*length)); + rest.split_at_checked(length) + .ok_or_else(|| framing(format!("{what} length {length} exceeds the message"))) +} + +impl UpdateMessage { + /// Decodes the body of an UPDATE message (what follows the 19-octet + /// header) with the error handling of RFC 7606. + /// + /// `Err` is an error that resets the session, with the NOTIFICATION to + /// send. Every other error is handled here and listed in the result. + pub fn decode(ctx: &ParserContext, body: &[u8]) -> Result { + let (withdrawn_buf, body) = length_prefixed(body, "withdrawn routes")?; + let (attributes_buf, nlri_buf) = length_prefixed(body, "path attribute")?; + + let withdrawn_routes = prefixes(ctx, "withdrawn routes", withdrawn_buf)?; + let announced_routes = prefixes(ctx, "NLRI", nlri_buf)?; + + let mut reader = AttributeReader::new(ctx, attributes_buf.len()); + reader.read_all(attributes_buf)?; + let AttributeReader { + attributes: path_attributes, + mut errors, + kept_wire, + .. + } = reader; + let mut update = UpdateMessage { + withdrawn_routes, + path_attributes, + announced_routes, + }; + + let mut withdrawn = errors + .iter() + .any(|e| e.handling == Handling::TreatAsWithdraw); + if !withdrawn && let Some(missing) = missing_mandatory(ctx, &update) { + errors.push(UpdateError { + handling: Handling::TreatAsWithdraw, + attribute: Some(missing), + detail: format!("mandatory attribute {missing} is missing"), + }); + withdrawn = true; + } + if withdrawn { + update = as_withdrawal(update); + } + let attributes_wire = + (!withdrawn && ctx.four_octet_asn && !kept_wire.is_empty()).then_some(kept_wire); + Ok(DecodedUpdate { + update, + errors, + attributes_wire, + }) + } +} + +/// What to do with an attribute, once it is clear. +enum Verdict { + /// Use it. `route_property` is false for one that was read only to find + /// the routes it names, and is no part of the routes' attributes. + Keep { + attribute: PathAttribute, + route_property: bool, + }, + /// Leave it out, having noted why. + Skip, +} + +/// Reads the path attributes of one UPDATE, leaving out the ones that are +/// discarded and noting every error that does not reset the session. +struct AttributeReader<'a> { + ctx: &'a ParserContext, + attributes: Vec, + errors: Vec, + /// The bytes of the attributes that are kept and are a property of a + /// route: all but MP_REACH_NLRI and MP_UNREACH_NLRI. + kept_wire: Vec, + /// The type codes met so far, one bit each. + seen: [u64; 4], +} + +impl<'a> AttributeReader<'a> { + fn new(ctx: &'a ParserContext, wire_len: usize) -> Self { + Self { + ctx, + attributes: Vec::new(), + errors: Vec::new(), + kept_wire: Vec::with_capacity(wire_len), + seen: [0; 4], + } + } + + fn error(&mut self, handling: Handling, attribute: Option, detail: String) { + self.errors.push(UpdateError { + handling, + attribute, + detail, + }); + } + + /// Notes that `type_code` has been met, and returns whether it had + /// been already. + fn seen_before(&mut self, type_code: u8) -> bool { + let (word, bit) = (usize::from(type_code / 64), 1u64 << (type_code % 64)); + let before = self.seen[word] & bit != 0; + self.seen[word] |= bit; + before + } + + fn read_all(&mut self, mut buf: &[u8]) -> Result<(), DecodeError> { + while !buf.is_empty() { + let (raw, rest) = match split_attribute(buf) { + Ok(split) => split, + Err(e) => { + // An attribute that does not fit in what is left of the + // attributes (RFC 7606 §4). Where the next one would + // start is unknown, so the rest is not read. Routes + // found so far are withdrawn. + let attribute = match e { + NotAnAttribute::NoHeader => None, + NotAnAttribute::Truncated { type_code, .. } => Some(type_code), + }; + self.error(Handling::TreatAsWithdraw, attribute, e.to_string()); + return Ok(()); + } + }; + buf = rest; + if let Verdict::Keep { + attribute, + route_property, + } = self.judge(&raw)? + { + if route_property { + self.kept_wire.extend_from_slice(raw.wire); + } + self.attributes.push(attribute); + } + } + Ok(()) + } + + /// Decides what becomes of one attribute. `Err` resets the session. + fn judge(&mut self, raw: &RawAttribute<'_>) -> Result { + let (flags, type_code, wire) = (&raw.flags, raw.type_code, raw.wire); + let names_routes = matches!(type_code, MP_REACH_NLRI | MP_UNREACH_NLRI); + + // RFC 7606 §3.g: a second MP_REACH_NLRI or MP_UNREACH_NLRI makes it + // unclear which routes are meant; a second anything else is ignored. + if self.seen_before(type_code) { + if names_routes { + return Err(reset( + UpdateMsgSubcode::MalformedAttrs, + vec![], + format!("attribute {type_code} appears more than once"), + )); + } + self.error( + Handling::AttributeDiscard, + Some(type_code), + format!("attribute {type_code} appears more than once; kept the first"), + ); + return Ok(Verdict::Skip); + } + + let Some((optional, transitive)) = defined_flags(type_code) else { + // An attribute we have no definition for. One marked well-known + // is one every speaker is required to understand (RFC 4271 + // §6.3); an optional one is carried along as it is. + if !flags.optional() { + return Err(reset( + UpdateMsgSubcode::UnrecognizedWellKnownAttr, + wire.to_vec(), + format!("unrecognised well-known attribute {type_code}"), + )); + } + return Ok(Verdict::Keep { + attribute: opaque(raw), + route_property: true, + }); + }; + let flags_are_wrong = flags.optional() != optional || flags.transitive() != transitive; + if flags_are_wrong { + // RFC 7606 §3.c. The value is still read below if it is one that + // names routes, since those have to be withdrawn. + self.error( + Handling::TreatAsWithdraw, + Some(type_code), + format!( + "attribute {type_code} has flags {:#04x} that conflict with its definition", + flags.0 + ), + ); + if !names_routes { + return Ok(Verdict::Skip); + } + } + + // Attributes that only mean something between internal peers are + // dropped when an external one sends them (RFC 7606 §7.5, §7.9, + // §7.10), well formed or not. + if !self.ctx.ibgp && matches!(type_code, LOCAL_PREF | ORIGINATOR_ID | CLUSTER_LIST) { + self.error( + Handling::AttributeDiscard, + Some(type_code), + format!("attribute {type_code} from an external peer"), + ); + return Ok(Verdict::Skip); + } + // A speaker with four-octet AS numbers has no use for AS4_PATH and + // AS4_AGGREGATOR and must not send them to another (RFC 6793 §6). + if self.ctx.four_octet_asn && matches!(type_code, AS4_PATH | AS4_AGGREGATOR) { + self.error( + Handling::AttributeDiscard, + Some(type_code), + format!("attribute {type_code} on a session with four-octet AS numbers"), + ); + return Ok(Verdict::Skip); + } + + match self.parse(raw) { + Ok(attribute) => Ok(Verdict::Keep { + attribute, + route_property: !flags_are_wrong && !names_routes, + }), + Err(detail) => { + let detail = format!("malformed attribute {type_code}: {detail}"); + match handling_of_malformed(type_code) { + Some(handling) => { + self.error(handling, Some(type_code), detail); + Ok(Verdict::Skip) + } + None => Err(reset( + UpdateMsgSubcode::OptionalAttribute, + wire.to_vec(), + detail, + )), + } + } + } + } + + /// Reads the value of an attribute whose definition is known. The error + /// is what was wrong with it, as text. + fn parse(&self, raw: &RawAttribute<'_>) -> Result { + let value = raw.value; + // Known by their definition but not modelled: kept as bytes, once + // their length has been checked. + let length_is_valid = match raw.type_code { + ORIGINATOR_ID => Some(value.len() == 4), + CLUSTER_LIST => Some(!value.is_empty() && value.len().is_multiple_of(4)), + AS4_AGGREGATOR => Some(value.len() == 8), + _ => None, + }; + match length_is_valid { + Some(true) => Ok(opaque(raw)), + Some(false) => Err(format!("a length of {} is not valid", value.len())), + None => PathAttribute::parse_value(self.ctx, raw.flags.clone(), raw.type_code, value) + .map_err(|e| describe(&e)), + } + } +} + +/// The attribute kept as bytes. +fn opaque(raw: &RawAttribute<'_>) -> PathAttribute { + PathAttribute::UnknownPathAttribute { + flags: raw.flags.clone(), + type_code: raw.type_code, + payload: raw.value.to_vec(), + } +} + +/// The type code of a mandatory attribute that an UPDATE announcing routes +/// lacks (RFC 4271 §5, RFC 4760 §3, RFC 7606 §3.d). An UPDATE that only +/// withdraws needs no attributes. +fn missing_mandatory(ctx: &ParserContext, update: &UpdateMessage) -> Option { + let in_body = !update.announced_routes.is_empty(); + let in_mp_reach = update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::MpReachNlri(mp) if !mp.prefixes.is_empty())); + if !in_body && !in_mp_reach { + return None; + } + // Routes in MP_REACH_NLRI carry their next hop there; LOCAL_PREF is + // for internal peers. + let required = [ + (ORIGIN, true), + (AS_PATH, true), + (NEXT_HOP, in_body), + (LOCAL_PREF, ctx.ibgp), + ]; + required + .into_iter() + .filter(|(_, needed)| *needed) + .map(|(type_code, _)| type_code) + .find(|type_code| { + !update + .path_attributes + .iter() + .any(|attribute| attribute.type_code() == *type_code) + }) +} + +/// `update` with every route it announces withdrawn instead, and no other +/// attributes: they are not to be trusted, and a withdrawal has none. +fn as_withdrawal(update: UpdateMessage) -> UpdateMessage { + let mut withdrawn_routes = update.withdrawn_routes; + withdrawn_routes.extend(update.announced_routes); + + let mut unreach: Vec = Vec::new(); + let mut withdraw = + |family, prefixes: Vec| match unreach.iter_mut().find(|u| u.family == family) { + Some(existing) => existing.prefixes.extend(prefixes), + None => unreach.push(MpUnreachNlriPathAttribute { family, prefixes }), + }; + for attribute in update.path_attributes { + match attribute { + PathAttribute::MpUnreachNlri(mp) => withdraw(mp.family, mp.prefixes), + PathAttribute::MpReachNlri(mp) => withdraw(mp.family, mp.prefixes), + _ => {} + } + } + UpdateMessage { + withdrawn_routes, + path_attributes: unreach + .into_iter() + .map(PathAttribute::MpUnreachNlri) + .collect(), + announced_routes: vec![], + } +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use crate::message::PathAttributeFlags; + use bgp_primitives::ip_prefix::IpPrefix; + + use super::*; + use crate::message::{ + AsPathAttribute, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute, + OriginPathAttribute, + }; + + const MED: u8 = 4; + const COMMUNITIES: u8 = 8; + const EXTENDED_COMMUNITIES: u8 = 16; + const LARGE_COMMUNITY: u8 = 32; + + const WELL_KNOWN: u8 = 0x40; + const OPTIONAL: u8 = 0x80; + const OPTIONAL_TRANSITIVE: u8 = 0xc0; + + fn ebgp() -> ParserContext { + ParserContext { + four_octet_asn: true, + ibgp: false, + add_path: Vec::new(), + } + } + + fn ibgp() -> ParserContext { + ParserContext { + four_octet_asn: true, + ibgp: true, + add_path: Vec::new(), + } + } + + fn attr(flags: u8, type_code: u8, value: &[u8]) -> Vec { + let mut wire = vec![flags, type_code, value.len() as u8]; + wire.extend_from_slice(value); + wire + } + + fn origin() -> Vec { + attr(WELL_KNOWN, ORIGIN, &[0]) + } + + /// AS_SEQUENCE of AS65001, four octets wide. + fn as_path() -> Vec { + attr(WELL_KNOWN, AS_PATH, &[2, 1, 0, 0, 0xfd, 0xe9]) + } + + fn next_hop() -> Vec { + attr(WELL_KNOWN, NEXT_HOP, &[192, 0, 2, 1]) + } + + fn local_pref() -> Vec { + attr(WELL_KNOWN, LOCAL_PREF, &[0, 0, 0, 100]) + } + + /// The three attributes every IPv4 announcement needs. + fn mandatory() -> Vec> { + vec![origin(), as_path(), next_hop()] + } + + /// 10.1.2.0/24 + const NLRI: [u8; 4] = [24, 10, 1, 2]; + + fn route() -> IpPrefix { + IpPrefix::try_from("10.1.2.0/24").unwrap() + } + + fn body(withdrawn: &[u8], attributes: &[Vec], nlri: &[u8]) -> Vec { + let attributes = attributes.concat(); + let mut body = (withdrawn.len() as u16).to_be_bytes().to_vec(); + body.extend_from_slice(withdrawn); + body.extend_from_slice(&(attributes.len() as u16).to_be_bytes()); + body.extend_from_slice(&attributes); + body.extend_from_slice(nlri); + body + } + + /// An announcement of 10.1.2.0/24 with the mandatory attributes and + /// `extra`. + fn announce_with(ctx: &ParserContext, extra: Vec) -> DecodedUpdate { + let mut attributes = mandatory(); + attributes.push(extra); + UpdateMessage::decode(ctx, &body(&[], &attributes, &NLRI)).unwrap() + } + + fn handled(decoded: &DecodedUpdate) -> Vec<(Handling, Option)> { + decoded + .errors + .iter() + .map(|e| (e.handling, e.attribute)) + .collect() + } + + #[track_caller] + fn assert_withdrawn(decoded: &DecodedUpdate, attribute: Option, case: &str) { + assert_eq!( + handled(decoded), + [(Handling::TreatAsWithdraw, attribute)], + "{case}" + ); + assert_eq!(decoded.update.withdrawn_routes, [route()], "{case}"); + assert!(decoded.update.announced_routes.is_empty(), "{case}"); + assert!(decoded.update.path_attributes.is_empty(), "{case}"); + } + + #[test] + fn a_well_formed_update_is_left_alone() { + let decoded = announce_with(&ebgp(), attr(OPTIONAL, MED, &[0, 0, 0, 50])); + assert!(decoded.errors.is_empty()); + assert_eq!(decoded.update.announced_routes, [route()]); + assert_eq!( + decoded.update.path_attributes, + [ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![65001]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)), + ] + ); + } + + #[test] + fn the_kept_attributes_are_handed_over_as_they_were_on_the_wire() { + // All of a well-formed UPDATE's attributes, but not MP_REACH_NLRI. + let attributes = vec![mp_reach_v6(), origin(), as_path(), next_hop()]; + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_eq!( + decoded.attributes_wire, + Some([origin(), as_path(), next_hop()].concat()) + ); + + // Not an attribute that was discarded. + let decoded = announce_with(&ebgp(), local_pref()); + assert_eq!(decoded.attributes_wire, Some(mandatory().concat())); + + // Nothing when the routes were withdrawn instead: there is no route + // for the attributes to belong to. + let decoded = announce_with(&ebgp(), attr(OPTIONAL, MED, &[1])); + assert_eq!(decoded.attributes_wire, None); + let decoded = UpdateMessage::decode(&ebgp(), &body(&NLRI, &[], &[])).unwrap(); + assert_eq!(decoded.attributes_wire, None); + + // Nor on a session with two-octet AS numbers, where the bytes are + // not in the form a table keeps. + let two_octet = ParserContext { + four_octet_asn: false, + ibgp: false, + add_path: Vec::new(), + }; + let attributes = vec![ + origin(), + attr(WELL_KNOWN, AS_PATH, &[2, 1, 0xfd, 0xe9]), + next_hop(), + ]; + let decoded = UpdateMessage::decode(&two_octet, &body(&[], &attributes, &NLRI)).unwrap(); + assert!(decoded.errors.is_empty()); + assert_eq!(decoded.attributes_wire, None); + } + + #[test] + fn an_update_that_announces_nothing_needs_no_attributes() { + // End-of-RIB, and a plain withdrawal. + for wire in [body(&[], &[], &[]), body(&NLRI, &[], &[])] { + let decoded = UpdateMessage::decode(&ebgp(), &wire).unwrap(); + assert!(decoded.errors.is_empty()); + } + } + + #[test] + fn a_malformed_attribute_withdraws_the_routes_and_keeps_the_session() { + let cases: Vec<(&str, u8, Vec)> = vec![ + ("MED of three octets", MED, attr(OPTIONAL, MED, &[0, 0, 50])), + ( + "COMMUNITIES with nothing in it", + COMMUNITIES, + attr(OPTIONAL_TRANSITIVE, COMMUNITIES, &[]), + ), + ( + "COMMUNITIES that is not a multiple of four", + COMMUNITIES, + attr(OPTIONAL_TRANSITIVE, COMMUNITIES, &[0xfd, 0xe8, 0, 100, 1]), + ), + ( + "EXTENDED COMMUNITIES of seven octets", + EXTENDED_COMMUNITIES, + attr(OPTIONAL_TRANSITIVE, EXTENDED_COMMUNITIES, &[0; 7]), + ), + ( + "LARGE_COMMUNITY of eleven octets", + LARGE_COMMUNITY, + attr(OPTIONAL_TRANSITIVE, LARGE_COMMUNITY, &[0; 11]), + ), + ( + "MED marked transitive", + MED, + attr(OPTIONAL_TRANSITIVE, MED, &[0, 0, 0, 50]), + ), + ]; + for (case, type_code, extra) in cases { + let decoded = announce_with(&ebgp(), extra); + assert_withdrawn(&decoded, Some(type_code), case); + } + } + + #[test] + fn a_malformed_mandatory_attribute_withdraws_the_routes() { + // Each replaces the good attribute of its type. + let cases: Vec<(&str, u8, Vec)> = vec![ + ("ORIGIN of 5", ORIGIN, attr(WELL_KNOWN, ORIGIN, &[5])), + ( + "ORIGIN of two octets", + ORIGIN, + attr(WELL_KNOWN, ORIGIN, &[0, 0]), + ), + ( + "ORIGIN marked optional", + ORIGIN, + attr(OPTIONAL_TRANSITIVE, ORIGIN, &[0]), + ), + ( + "AS_PATH with a confederation segment", + AS_PATH, + attr(WELL_KNOWN, AS_PATH, &[3, 1, 0, 0, 0xfd, 0xe9]), + ), + ( + "AS_PATH with an empty segment", + AS_PATH, + attr(WELL_KNOWN, AS_PATH, &[2, 0]), + ), + ( + "AS_PATH cut short", + AS_PATH, + attr(WELL_KNOWN, AS_PATH, &[2, 2, 0, 0, 0xfd, 0xe9]), + ), + ( + "NEXT_HOP of three octets", + NEXT_HOP, + attr(WELL_KNOWN, NEXT_HOP, &[192, 0, 2]), + ), + ]; + for (case, type_code, bad) in cases { + let attributes: Vec> = mandatory() + .into_iter() + .map(|good| { + if good[1] == type_code { + bad.clone() + } else { + good + } + }) + .collect(); + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_withdrawn(&decoded, Some(type_code), case); + } + } + + #[test] + fn a_missing_mandatory_attribute_withdraws_the_routes() { + for missing in [ORIGIN, AS_PATH, NEXT_HOP] { + let attributes: Vec> = mandatory() + .into_iter() + .filter(|a| a[1] != missing) + .collect(); + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_withdrawn(&decoded, Some(missing), &format!("without {missing}")); + } + } + + #[test] + fn an_attribute_that_overruns_the_attributes_withdraws_what_was_found() { + // A MED that claims 200 octets. + let mut attributes = mandatory(); + attributes.push(vec![OPTIONAL, MED, 200, 0, 0]); + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_withdrawn(&decoded, Some(MED), "overrun"); + + // Two stray octets after the last attribute: not even a header. + let mut attributes = mandatory(); + attributes.push(vec![OPTIONAL, MED]); + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_withdrawn(&decoded, None, "stray octets"); + } + + #[test] + fn attributes_that_do_not_affect_selection_are_dropped_and_the_route_kept() { + let cases: Vec<(&str, u8, Vec)> = vec![ + ( + "ATOMIC_AGGREGATE with a value", + ATOMIC_AGGREGATE, + attr(WELL_KNOWN, ATOMIC_AGGREGATE, &[1]), + ), + ( + "AGGREGATOR of five octets", + AGGREGATOR, + attr(OPTIONAL_TRANSITIVE, AGGREGATOR, &[0; 5]), + ), + ("LOCAL_PREF from an external peer", LOCAL_PREF, local_pref()), + ( + "ORIGINATOR_ID from an external peer", + ORIGINATOR_ID, + attr(OPTIONAL, ORIGINATOR_ID, &[10, 0, 0, 1]), + ), + ( + "CLUSTER_LIST from an external peer", + CLUSTER_LIST, + attr(OPTIONAL, CLUSTER_LIST, &[10, 0, 0, 1]), + ), + ( + "AS4_PATH between four-octet speakers", + AS4_PATH, + attr(OPTIONAL_TRANSITIVE, AS4_PATH, &[2, 1, 0, 0, 0xfd, 0xe9]), + ), + ("a second ORIGIN", ORIGIN, origin()), + ]; + for (case, type_code, extra) in cases { + let decoded = announce_with(&ebgp(), extra); + assert_eq!( + handled(&decoded), + [(Handling::AttributeDiscard, Some(type_code))], + "{case}" + ); + assert_eq!(decoded.update.announced_routes, [route()], "{case}"); + assert_eq!(decoded.update.path_attributes.len(), 3, "{case}"); + } + } + + #[test] + fn a_repeated_attribute_keeps_its_first_value() { + let mut attributes = mandatory(); + attributes.push(attr(OPTIONAL, MED, &[0, 0, 0, 50])); + attributes.push(attr(OPTIONAL, MED, &[0, 0, 0, 99])); + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_eq!(handled(&decoded), [(Handling::AttributeDiscard, Some(MED))]); + assert!( + decoded + .update + .path_attributes + .contains(&PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute( + 50 + ))) + ); + } + + #[test] + fn internal_peers_must_send_local_pref_and_may_send_reflection_attributes() { + let kept = announce_with(&ibgp(), local_pref()); + assert!(kept.errors.is_empty()); + assert!( + kept.update + .path_attributes + .contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(100))) + ); + + let missing = UpdateMessage::decode(&ibgp(), &body(&[], &mandatory(), &NLRI)).unwrap(); + assert_withdrawn(&missing, Some(LOCAL_PREF), "no LOCAL_PREF"); + + let short = announce_with(&ibgp(), attr(WELL_KNOWN, LOCAL_PREF, &[0, 0, 100])); + assert_withdrawn(&short, Some(LOCAL_PREF), "short LOCAL_PREF"); + + let mut attributes = mandatory(); + attributes.push(local_pref()); + attributes.push(attr(OPTIONAL, ORIGINATOR_ID, &[10, 0, 0, 1])); + attributes.push(attr(OPTIONAL, CLUSTER_LIST, &[10, 0, 0, 2, 10, 0, 0, 3])); + let reflected = UpdateMessage::decode(&ibgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert!(reflected.errors.is_empty()); + assert_eq!(reflected.update.path_attributes.len(), 6); + + let mut attributes = mandatory(); + attributes.push(local_pref()); + attributes.push(attr(OPTIONAL, ORIGINATOR_ID, &[10, 0, 1])); + let bad = UpdateMessage::decode(&ibgp(), &body(&[], &attributes, &NLRI)).unwrap(); + assert_withdrawn(&bad, Some(ORIGINATOR_ID), "short ORIGINATOR_ID"); + } + + #[test] + fn as4_path_is_read_from_a_two_octet_peer_and_dropped_if_malformed() { + let ctx = ParserContext { + four_octet_asn: false, + ibgp: false, + add_path: Vec::new(), + }; + // On this session AS_PATH is two octets wide. + let attributes = |as4_path: Vec| { + vec![ + origin(), + attr(WELL_KNOWN, AS_PATH, &[2, 1, 0x5b, 0xa0]), + next_hop(), + as4_path, + ] + }; + let good = attr( + OPTIONAL_TRANSITIVE, + AS4_PATH, + &[2, 1, 0xfa, 0x56, 0xea, 0x01], + ); + let decoded = UpdateMessage::decode(&ctx, &body(&[], &attributes(good), &NLRI)).unwrap(); + assert!(decoded.errors.is_empty()); + assert!( + decoded + .update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::As4Path(_))) + ); + + let bad = attr(OPTIONAL_TRANSITIVE, AS4_PATH, &[2, 1, 0xfa, 0x56]); + let decoded = UpdateMessage::decode(&ctx, &body(&[], &attributes(bad), &NLRI)).unwrap(); + assert_eq!( + handled(&decoded), + [(Handling::AttributeDiscard, Some(AS4_PATH))] + ); + assert_eq!(decoded.update.announced_routes, [route()]); + } + + /// 2001:db8:1::/48 via 2001:db8::1. + fn mp_reach_v6() -> Vec { + let mut value = vec![0, 2, 1, 16]; + value.extend_from_slice(&[0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); + value.extend_from_slice(&[0, 48, 0x20, 0x01, 0x0d, 0xb8, 0x00, 0x01]); + attr(OPTIONAL, MP_REACH_NLRI, &value) + } + + #[test] + fn routes_in_mp_reach_are_withdrawn_too() { + let v6 = IpPrefix::try_from("2001:db8:1::/48").unwrap(); + let withdrawn = |attributes: Vec>, case: &str| { + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &attributes, &[])).unwrap(); + assert!( + decoded + .errors + .iter() + .any(|e| e.handling == Handling::TreatAsWithdraw), + "{case}" + ); + assert_eq!( + decoded.update.path_attributes, + [PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + prefixes: vec![v6.into()], + })], + "{case}" + ); + }; + + // A well-formed one first: no NEXT_HOP attribute is needed. + let good = vec![mp_reach_v6(), origin(), as_path()]; + let decoded = UpdateMessage::decode(&ebgp(), &body(&[], &good, &[])).unwrap(); + assert!(decoded.errors.is_empty()); + assert!(matches!( + &decoded.update.path_attributes[0], + PathAttribute::MpReachNlri(mp) if mp.family == AfiSafi::IPV6_UNICAST + )); + + withdrawn( + vec![mp_reach_v6(), attr(WELL_KNOWN, ORIGIN, &[9]), as_path()], + "bad ORIGIN", + ); + withdrawn(vec![mp_reach_v6(), origin()], "no AS_PATH"); + // Wrong flags on MP_REACH_NLRI itself: its routes are still found. + let mut misflagged = mp_reach_v6(); + misflagged[0] = OPTIONAL_TRANSITIVE; + withdrawn(vec![misflagged, origin(), as_path()], "misflagged"); + } + + #[test] + fn errors_that_leave_the_routes_in_doubt_reset_the_session() { + let subcode = |wire: Vec| match UpdateMessage::decode(&ebgp(), &wire) { + Err(e) => (e.notification, e.data), + Ok(decoded) => panic!("expected a reset, got {decoded:?}"), + }; + let update_error = |subcode| BgpError::UpdateMsg(subcode); + + // The lengths that frame the message do not fit it. + assert_eq!( + subcode(vec![0, 9, 24, 10, 1, 2, 0, 0]).0, + update_error(UpdateMsgSubcode::MalformedAttrs) + ); + assert_eq!( + subcode(vec![0, 0, 0, 9, 0x40, 1, 1, 0]).0, + update_error(UpdateMsgSubcode::MalformedAttrs) + ); + assert_eq!( + subcode(vec![0]).0, + update_error(UpdateMsgSubcode::MalformedAttrs) + ); + + // A prefix that cannot be one. + assert_eq!( + subcode(body(&[], &mandatory(), &[33, 10, 1, 2, 3, 4])).0, + update_error(UpdateMsgSubcode::InvalidNetworkField) + ); + assert_eq!( + subcode(body(&[24, 10, 1], &[], &[])).0, + update_error(UpdateMsgSubcode::InvalidNetworkField) + ); + + // Two MP_REACH_NLRI: which routes are meant? + assert_eq!( + subcode(body( + &[], + &[mp_reach_v6(), mp_reach_v6(), origin(), as_path()], + &[] + )) + .0, + update_error(UpdateMsgSubcode::MalformedAttrs) + ); + + // MP_REACH_NLRI with a next hop of the wrong length. + let bad_mp_reach = attr(OPTIONAL, MP_REACH_NLRI, &[0, 2, 1, 3, 1, 2, 3, 0]); + let (error, data) = subcode(body(&[], &[bad_mp_reach.clone(), origin(), as_path()], &[])); + assert_eq!(error, update_error(UpdateMsgSubcode::OptionalAttribute)); + assert_eq!(data, bad_mp_reach); + + // An attribute nobody has defined, marked as one everybody must know. + let unknown = attr(WELL_KNOWN, 99, &[1, 2]); + let mut attributes = mandatory(); + attributes.push(unknown.clone()); + let (error, data) = subcode(body(&[], &attributes, &NLRI)); + assert_eq!( + error, + update_error(UpdateMsgSubcode::UnrecognizedWellKnownAttr) + ); + assert_eq!(data, unknown); + } + + #[test] + fn an_unknown_optional_attribute_is_carried_as_it_came() { + let decoded = announce_with(&ebgp(), attr(0xe0, 99, &[1, 2, 3])); + assert!(decoded.errors.is_empty()); + assert_eq!( + decoded.update.path_attributes[3], + PathAttribute::UnknownPathAttribute { + flags: PathAttributeFlags(0xe0), + type_code: 99, + payload: vec![1, 2, 3], + } + ); + } +} diff --git a/crates/packet/tests/properties.rs b/crates/packet/tests/properties.rs new file mode 100644 index 0000000..3117dd4 --- /dev/null +++ b/crates/packet/tests/properties.rs @@ -0,0 +1,674 @@ +//! Property tests for the wire codec. +//! +//! Two things are checked for every kind of message: +//! +//! - **Round trip.** A message that can be written can be read back, comes +//! back equal. +//! - **Hostile input.** Whatever bytes arrive, decoding returns a message or +//! an error: it never panics. What it does accept can be shown, written +//! again, and read back as the same message. +//! +//! The second is what a peer can do to us, so it is run on three kinds of +//! input: plain noise, noise behind a valid header (so it reaches the body +//! parsers), and valid messages with a few bytes damaged (so it reaches deep +//! into them). + +use std::net::{Ipv4Addr, Ipv6Addr}; + +use bgp_packet::errors::BgpError; +use bgp_packet::message::{ + AggregatorPathAttribute, AsPathAttribute, AsPathSegment, AtomicAggregatePathAttribute, + BGP_HEADER_LEN, BGP_MAX_MSG_LEN, CommunitiesPathAttribute, ExtendedCommunitiesPathAttribute, + ExtendedCommunity, LargeCommunitiesPathAttribute, LargeCommunity, LocalPrefPathAttribute, + Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, MultiExitDiscPathAttribute, + NextHopPathAttribute, NlriNextHop, NotificationMessage, OriginPathAttribute, PathAttribute, + PathAttributeFlags, RouteRefreshMessage, UpdateMessage, +}; +use bgp_packet::open::{ + AddPathEntry, AddPathSendRecv, BgpRole, BgpSecHeader, Capability, ExtendedNextHop, + GracefulRestartEntry, LongLivedGracefulRestart, MultiLabelEntry, OpenMessage, OpenOption, + OrfFamily, OrfSendRecv, OrfType, OutboundRouteFilterEntry, +}; +use bgp_primitives::constants::{Afi, AfiSafi, IpVersion, Safi}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_primitives::parser::{EncodeError, ParserContext}; +use bytes::BytesMut; +use proptest::collection::vec; +use proptest::prelude::*; + +/// What a session settled that the codec depends on. +#[derive(Debug, Clone, Copy)] +struct Session { + four_octet_asn: bool, + ibgp: bool, + /// Whether prefixes carry path identifiers (RFC 7911), for IPv4 and + /// for IPv6 unicast. + add_path: [bool; 2], +} + +impl Session { + fn ctx(self) -> ParserContext { + ParserContext { + four_octet_asn: self.four_octet_asn, + ibgp: self.ibgp, + add_path: [AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST] + .into_iter() + .zip(self.add_path) + .filter_map(|(family, in_use)| in_use.then_some(family)) + .collect(), + } + } + + fn has_add_path(self, version: IpVersion, safi: Safi) -> bool { + safi == Safi::UNICAST + && match version { + IpVersion::Ipv4 => self.add_path[0], + IpVersion::Ipv6 => self.add_path[1], + } + } +} + +fn session() -> impl Strategy { + any::<(bool, bool, [bool; 2])>().prop_map(|(four_octet_asn, ibgp, add_path)| Session { + four_octet_asn, + ibgp, + add_path, + }) +} + +// --------------------------------------------------------------------------- +// Generators +// --------------------------------------------------------------------------- + +fn prefix(version: IpVersion) -> impl Strategy { + let max_len = match version { + IpVersion::Ipv4 => 32u8, + IpVersion::Ipv6 => 128, + }; + (0..=max_len, any::<[u8; 16]>()).prop_map(move |(length, bytes)| { + IpPrefix::new(version, bytes, length).expect("16 bytes are enough for any prefix") + }) +} + +/// Prefixes as an UPDATE names them. Without ADD-PATH for the family there +/// is no identifier on the wire, and it reads back as zero. +fn nlri(session: Session, version: IpVersion, safi: Safi) -> impl Strategy> { + let with_path_id = session.has_add_path(version, safi); + vec( + (prefix(version), any::()).prop_map(move |(prefix, path_id)| Nlri { + prefix, + path_id: if with_path_id { path_id } else { 0 }, + }), + 0..5, + ) +} + +fn afi() -> impl Strategy { + any::().prop_map(Afi) +} + +fn safi() -> impl Strategy { + any::().prop_map(Safi) +} + +/// AS numbers that fit the session: any on a four-octet one, two-octet ones +/// otherwise (wider ones are replaced by AS_TRANS on the wire, by design). +fn asn(four_octet_asn: bool) -> BoxedStrategy { + if four_octet_asn { + any::().boxed() + } else { + any::().prop_map(u32::from).boxed() + } +} + +fn as_path(asn: BoxedStrategy) -> impl Strategy { + // A segment has at least one AS number. + vec((any::(), vec(asn, 1..6)), 0..4).prop_map(|segments| AsPathAttribute { + segments: segments + .into_iter() + .map(|(ordered, path)| AsPathSegment { ordered, path }) + .collect(), + }) +} + +fn extended_community() -> impl Strategy { + any::<[u8; 8]>().prop_map(ExtendedCommunity) +} + +fn mp_reach(session: Session) -> impl Strategy { + let ip_safi = || prop_oneof![Just(Safi::UNICAST), Just(Safi::MULTICAST)]; + let v4 = ip_safi() + .prop_flat_map(move |safi| { + ( + Just(safi), + any::<[u8; 4]>(), + nlri(session, IpVersion::Ipv4, safi), + ) + }) + .prop_map(|(safi, next_hop, prefixes)| MpReachNlriPathAttribute { + family: AfiSafi::new(Afi::IPV4, safi), + next_hop: NlriNextHop::Ipv4(Ipv4Addr::from(next_hop)), + prefixes, + }); + let v6_next_hop = || { + prop_oneof![ + any::<[u8; 16]>().prop_map(|a| NlriNextHop::Ipv6(Ipv6Addr::from(a))), + (any::<[u8; 16]>(), any::<[u8; 16]>()).prop_map(|(global, link_local)| { + NlriNextHop::Ipv6WithLl { + global: Ipv6Addr::from(global), + link_local: Ipv6Addr::from(link_local), + } + }), + ] + }; + let v6 = ip_safi() + .prop_flat_map(move |safi| { + ( + Just(safi), + v6_next_hop(), + nlri(session, IpVersion::Ipv6, safi), + ) + }) + .prop_map(|(safi, next_hop, prefixes)| MpReachNlriPathAttribute { + family: AfiSafi::new(Afi::IPV6, safi), + next_hop, + prefixes, + }); + prop_oneof![v4, v6] +} + +fn mp_unreach(session: Session) -> impl Strategy { + let of = move |version: IpVersion| { + nlri(session, version, Safi::UNICAST).prop_map(move |prefixes| MpUnreachNlriPathAttribute { + family: AfiSafi::new(version.into(), Safi::UNICAST), + prefixes, + }) + }; + prop_oneof![of(IpVersion::Ipv4), of(IpVersion::Ipv6)] +} + +/// Type codes whose definition the decoder knows; an unknown attribute has +/// none of these. +const DEFINED_ATTRIBUTES: [u8; 16] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 14, 15, 16, 17, 18, 32]; + +/// Unknown attributes, each with a type code of its own: a repeated +/// attribute is dropped by the decoder. +fn unknown_attributes() -> impl Strategy> { + proptest::collection::btree_map( + any::().prop_filter("a type code without a definition", |code| { + !DEFINED_ATTRIBUTES.contains(code) + }), + (any::(), vec(any::(), 0..300)), + 0..3, + ) + .prop_map(|attributes| { + attributes + .into_iter() + .map(|(type_code, (flags, payload))| { + // Optional (bit 7): an unknown well-known attribute is an + // error. Bit 4 selects the two-octet length, which a payload + // this long needs. + let mut flags = flags | 0x80; + if payload.len() > 255 { + flags |= 0x10; + } + PathAttribute::UnknownPathAttribute { + flags: PathAttributeFlags(flags), + type_code, + payload, + } + }) + .collect() + }) +} + +/// The attributes of a well-formed UPDATE on `session`: the mandatory ones, +/// and any of the others at most once. +fn path_attributes(session: Session) -> impl Strategy> { + let mandatory = ( + prop_oneof![ + Just(OriginPathAttribute::IGP), + Just(OriginPathAttribute::EGP), + Just(OriginPathAttribute::INCOMPLETE) + ], + as_path(asn(session.four_octet_asn)), + any::<[u8; 4]>(), + any::(), + ) + .prop_map(move |(origin, as_path, next_hop, local_pref)| { + let mut attributes = vec![ + PathAttribute::Origin(origin), + PathAttribute::AsPath(as_path), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::from(next_hop))), + ]; + // Mandatory between internal peers, dropped from external ones. + if session.ibgp { + attributes.push(PathAttribute::LocalPref(LocalPrefPathAttribute(local_pref))); + } + attributes + }); + let optional = ( + proptest::option::of( + any::().prop_map(|v| PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(v))), + ), + proptest::option::of(Just(PathAttribute::AtomicAggregate( + AtomicAggregatePathAttribute {}, + ))), + proptest::option::of((asn(session.four_octet_asn), any::<[u8; 4]>()).prop_map( + |(asn, ip)| { + PathAttribute::Aggregator(AggregatorPathAttribute { + asn, + ip: Ipv4Addr::from(ip), + }) + }, + )), + proptest::option::of( + vec(any::<(u16, u16)>(), 1..8) + .prop_map(|c| PathAttribute::Communities(CommunitiesPathAttribute(c))), + ), + proptest::option::of(mp_reach(session).prop_map(PathAttribute::MpReachNlri)), + proptest::option::of(mp_unreach(session).prop_map(PathAttribute::MpUnreachNlri)), + proptest::option::of( + vec(extended_community(), 1..6).prop_map(|extended_communities| { + PathAttribute::ExtendedCommunities(ExtendedCommunitiesPathAttribute { + extended_communities, + }) + }), + ), + // AS4_PATH is four octets wide, and only a two-octet speaker sends + // it. + proptest::option::of(as_path(any::().boxed()).prop_map(PathAttribute::As4Path)) + .prop_map(move |as4_path| as4_path.filter(|_| !session.four_octet_asn)), + proptest::option::of(vec(any::<(u32, u32, u32)>(), 1..6).prop_map(|communities| { + PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute { + communities: communities + .into_iter() + .map(|(global_admin, data_1, data_2)| LargeCommunity { + global_admin, + data_1, + data_2, + }) + .collect(), + }) + })), + unknown_attributes(), + ); + (mandatory, optional).prop_map(|(mut attributes, optional)| { + let (med, atomic, aggregator, communities, reach, unreach, extended, as4, large, unknown) = + optional; + attributes.extend( + [ + med, + atomic, + aggregator, + communities, + reach, + unreach, + extended, + as4, + large, + ] + .into_iter() + .flatten(), + ); + attributes.extend(unknown); + attributes + }) +} + +fn update(session: Session) -> impl Strategy { + ( + nlri(session, IpVersion::Ipv4, Safi::UNICAST), + path_attributes(session), + nlri(session, IpVersion::Ipv4, Safi::UNICAST), + ) + .prop_map( + |(withdrawn_routes, path_attributes, announced_routes)| UpdateMessage { + withdrawn_routes, + path_attributes, + announced_routes, + }, + ) +} + +/// Capability codes that have their own variant. +const KNOWN_CAPABILITIES: [u8; 13] = [1, 2, 3, 5, 6, 7, 8, 9, 64, 65, 69, 70, 71]; + +fn capability() -> impl Strategy { + let orf = ( + any::().prop_map(OrfType), + prop_oneof![ + Just(OrfSendRecv::Receive), + Just(OrfSendRecv::Send), + Just(OrfSendRecv::Both) + ], + ) + .prop_map(|(r#type, send_recv)| OutboundRouteFilterEntry { r#type, send_recv }); + let add_path = prop_oneof![ + Just(AddPathSendRecv::Receive), + Just(AddPathSendRecv::Send), + Just(AddPathSendRecv::Both) + ]; + prop_oneof![ + (afi(), safi()).prop_map(|(afi, safi)| Capability::MultiProtocol { afi, safi }), + Just(Capability::RouteRefresh), + vec((afi(), safi(), vec(orf, 0..4)), 0..3).prop_map(|families| { + Capability::OutboundRouteFilter( + families + .into_iter() + .map(|(afi, safi, orfs)| OrfFamily { afi, safi, orfs }) + .collect(), + ) + }), + vec((afi(), safi(), afi()), 1..4).prop_map(|entries| Capability::ExtendedNextHop { + entries: entries + .into_iter() + .map(|(afi, safi, nh_afi)| ExtendedNextHop { afi, safi, nh_afi }) + .collect(), + }), + Just(Capability::ExtendedMessage), + (any::(), afi()).prop_map(|(header, afi)| Capability::BgpSec { + header: BgpSecHeader(header), + afi, + }), + vec((afi(), safi(), any::()), 1..4).prop_map(|entries| { + Capability::MultiLabelCompat { + entries: entries + .into_iter() + .map(|(afi, safi, count)| MultiLabelEntry { afi, safi, count }) + .collect(), + } + }), + any::().prop_map(|role| Capability::BgpRole { + role: BgpRole::from(role), + }), + ( + any::(), + 0u16..4096, + vec((afi(), safi(), any::()), 0..4) + ) + .prop_map(|(restart_state, restart_time, entries)| { + Capability::GracefulRestart { + restart_state, + restart_time, + entries: entries + .into_iter() + .map(|(afi, safi, preserve_forwarding)| GracefulRestartEntry { + afi, + safi, + preserve_forwarding, + }) + .collect(), + } + }), + any::().prop_map(|asn| Capability::FourByteAsn { asn }), + vec((afi(), safi(), add_path), 1..4).prop_map(|entries| Capability::AddPath { + entries: entries + .into_iter() + .map(|(afi, safi, send_recv)| AddPathEntry { + afi, + safi, + send_recv, + }) + .collect(), + }), + Just(Capability::EnhancedRouteRefresh), + vec((afi(), safi(), any::(), 0u32..(1 << 24)), 0..4).prop_map(|entries| { + Capability::LongLivedGracefulRestart( + entries + .into_iter() + .map(|(afi, safi, flags, stale_time)| LongLivedGracefulRestart { + afi, + safi, + flags, + stale_time, + }) + .collect(), + ) + }), + ( + any::().prop_filter("a code without a variant", |code| { + !KNOWN_CAPABILITIES.contains(code) + }), + vec(any::(), 0..20) + ) + .prop_map(|(type_code, value)| Capability::UnknownCapability { type_code, value }), + ] +} + +fn open() -> impl Strategy { + ( + any::(), + any::(), + any::<[u8; 4]>(), + // Enough to go past 255 octets of options now and then, which + // switches to the extended length of RFC 9072. + vec(vec(capability(), 0..10), 0..8), + ) + .prop_map(|(asn, hold_time, identifier, options)| OpenMessage { + version: 4, + asn, + hold_time, + identifier: Ipv4Addr::from(identifier), + options: options.into_iter().map(OpenOption::Capabilities).collect(), + }) +} + +fn notification() -> impl Strategy { + // Any code and subcode: a peer may send ones we have no name for. + (any::(), any::(), vec(any::(), 0..40)).prop_map(|(code, subcode, data)| { + NotificationMessage { + error: BgpError::from_code_subcode(code, subcode), + data, + } + }) +} + +fn message(session: Session) -> impl Strategy { + prop_oneof![ + open().prop_map(Message::Open), + update(session).prop_map(Message::Update), + notification().prop_map(Message::Notification), + Just(Message::KeepAlive), + (afi(), any::(), safi()).prop_map(|(afi, subtype, safi)| { + Message::RouteRefresh(RouteRefreshMessage { afi, subtype, safi }) + }), + ] +} + +/// A session, and a well-formed message that fits it. +fn session_and_message() -> impl Strategy { + session().prop_flat_map(|session| (Just(session), message(session))) +} + +// --------------------------------------------------------------------------- +// Properties +// --------------------------------------------------------------------------- + +/// Decodes `wire` and checks what must hold for anything the decoder +/// accepts: it can be shown and written, and unless the decoder had to +/// repair it, it reads back as itself. +fn check_decoding(ctx: &ParserContext, wire: &[u8]) -> Result<(), TestCaseError> { + let Ok(decoded) = Message::decode_with_report(ctx, wire) else { + return Ok(()); + }; + let (msg, repairs) = (decoded.message, decoded.update_errors); + // Display of what a peer sent must not be able to panic: it is logged. + let _ = format!("{msg:?} {repairs:?}"); + if let Message::Update(update) = &msg { + for prefix in update + .withdrawn_routes + .iter() + .chain(&update.announced_routes) + { + let _ = prefix.to_string(); + } + for attribute in &update.path_attributes { + match attribute { + PathAttribute::MpReachNlri(mp) => { + mp.prefixes.iter().for_each(|p| drop(p.to_string())) + } + PathAttribute::MpUnreachNlri(mp) => { + mp.prefixes.iter().for_each(|p| drop(p.to_string())) + } + _ => {} + } + } + } + + let mut rewritten = BytesMut::new(); + match msg.to_wire(ctx, &mut rewritten) { + Ok(()) => {} + // Withdrawing what a full-size UPDATE announced can need more room + // than one message has. The result is acted on, never sent. + Err(EncodeError::TooLong { .. }) if !repairs.is_empty() => return Ok(()), + Err(e) => { + return Err(TestCaseError::fail(format!( + "accepted but cannot be written: {e}" + ))); + } + } + if repairs.is_empty() { + let again = Message::decode_with_report(ctx, &rewritten) + .map_err(|e| TestCaseError::fail(format!("written but cannot be read: {e}")))?; + prop_assert!(again.update_errors.is_empty(), "{:?}", again.update_errors); + prop_assert_eq!(again.message, msg); + } + Ok(()) +} + +/// Writes a generated message. `None` when it is too big for one BGP +/// message, or some part of it for its length field, which the generators +/// can produce and the encoder must refuse. +fn written(ctx: &ParserContext, msg: &Message) -> Option { + let mut wire = BytesMut::new(); + match msg.to_wire(ctx, &mut wire) { + Ok(()) => Some(wire), + Err(EncodeError::TooLong { .. }) => None, + } +} + +fn framed(msg_type: u8, body: &[u8]) -> Vec { + let mut wire = vec![0xff; 16]; + let length = u16::try_from(usize::from(BGP_HEADER_LEN) + body.len()).unwrap(); + wire.extend_from_slice(&length.to_be_bytes()); + wire.push(msg_type); + wire.extend_from_slice(body); + wire +} + +/// Ways to damage a message. +#[derive(Debug, Clone)] +enum Damage { + Set { at: prop::sample::Index, to: u8 }, + Remove { at: prop::sample::Index }, + Insert { at: prop::sample::Index, byte: u8 }, + Truncate { to: prop::sample::Index }, +} + +fn damage() -> impl Strategy { + prop_oneof![ + (any::(), any::()).prop_map(|(at, to)| Damage::Set { at, to }), + any::().prop_map(|at| Damage::Remove { at }), + (any::(), any::()) + .prop_map(|(at, byte)| Damage::Insert { at, byte }), + any::().prop_map(|to| Damage::Truncate { to }), + ] +} + +/// Applies `damage` to the body of `wire` and makes the header's length +/// right again, so that the damage is the body parsers' problem. +fn damaged(wire: &[u8], damage: &[Damage]) -> Vec { + let header = usize::from(BGP_HEADER_LEN); + let msg_type = wire[header - 1]; + let mut body = wire[header..].to_vec(); + for d in damage { + match d { + Damage::Set { at, to } if !body.is_empty() => { + let i = at.index(body.len()); + body[i] = *to; + } + Damage::Remove { at } if !body.is_empty() => { + body.remove(at.index(body.len())); + } + Damage::Insert { at, byte } => body.insert(at.index(body.len() + 1), *byte), + Damage::Truncate { to } if !body.is_empty() => body.truncate(to.index(body.len())), + _ => {} + } + } + body.truncate(usize::from(BGP_MAX_MSG_LEN - BGP_HEADER_LEN)); + framed(msg_type, &body) +} + +/// 2000 cases a property, which takes about a second. For a longer hunt +/// set `PROPTEST_CASES`, e.g. `PROPTEST_CASES=1000000 cargo test --release +/// -p bgp-packet --test properties`. +fn config() -> ProptestConfig { + let cases = std::env::var("PROPTEST_CASES") + .ok() + .and_then(|cases| cases.parse().ok()) + .unwrap_or(2000); + ProptestConfig { + cases, + // Failures are not saved to a file: one that is found becomes a + // named test next to the code it broke. + failure_persistence: None, + ..ProptestConfig::default() + } +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn a_message_that_can_be_written_reads_back_equal((session, msg) in session_and_message()) { + let ctx = session.ctx(); + let Some(wire) = written(&ctx, &msg) else { + return Ok(()); + }; + let decoded = Message::decode_with_report(&ctx, &wire) + .map_err(|e| TestCaseError::fail(format!("{e}")))?; + // What we write is well formed: nothing for the decoder to repair. + prop_assert!(decoded.update_errors.is_empty(), "{:?}", decoded.update_errors); + prop_assert_eq!(&decoded.message, &msg); + } + + #[test] + fn noise_is_never_more_than_an_error(session in session(), bytes in vec(any::(), 0..200)) { + check_decoding(&session.ctx(), &bytes)?; + } + + #[test] + fn noise_behind_a_valid_header_is_never_more_than_an_error( + session in session(), + msg_type in 0u8..7, + body in vec(any::(), 0..300), + ) { + check_decoding(&session.ctx(), &framed(msg_type, &body))?; + } + + #[test] + fn a_damaged_message_is_never_more_than_an_error( + (session, msg) in session_and_message(), + damage in vec(damage(), 1..4), + ) { + let ctx = session.ctx(); + let Some(wire) = written(&ctx, &msg) else { + return Ok(()); + }; + check_decoding(&ctx, &damaged(&wire, &damage))?; + } + + /// The parsers below the message level are public too. + #[test] + fn noise_is_never_more_than_an_error_for_the_parts( + session in session(), + bytes in vec(any::(), 0..120), + ) { + let ctx = session.ctx(); + let _ = OpenMessage::from_wire(&bytes); + let _ = Capability::from_wire(&bytes); + let _ = UpdateMessage::decode(&ctx, &bytes); + let _ = PathAttribute::from_wire(&ctx, &bytes); + } +} diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml index 20a6ec9..3894739 100644 --- a/crates/primitives/Cargo.toml +++ b/crates/primitives/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bgp-primitives" - description = "Primitive types for working with routing information" +authors.workspace = true edition.workspace = true homepage.workspace = true license.workspace = true @@ -11,9 +11,14 @@ version.workspace = true [dependencies] bitfield.workspace = true bytes.workspace = true -eyre.workspace = true nom.workspace = true serde.workspace = true serde_repr.workspace = true thiserror.workspace = true strum.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/primitives/src/constants.rs b/crates/primitives/src/constants.rs index d9bdf4d..337f8d5 100644 --- a/crates/primitives/src/constants.rs +++ b/crates/primitives/src/constants.rs @@ -1,80 +1,174 @@ use std::fmt::Display; -use eyre::bail; -use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde::Serialize; +use serde_repr::Serialize_repr; +use thiserror::Error; -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, Serialize_repr, Deserialize_repr)] +/// An address family number this implementation has no name for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("unknown address family identifier {0}")] +pub struct UnknownAfi(pub u16); + +/// The IP version of a prefix. For the address family field of a capability +/// or an MP_REACH_NLRI, which can name families that are not IP, see [`Afi`]. +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, Serialize_repr)] #[repr(u16)] -pub enum AddressFamilyId { +pub enum IpVersion { Ipv4 = 1, Ipv6 = 2, } -impl TryFrom for AddressFamilyId { - type Error = eyre::Error; - - fn try_from(value: u16) -> Result { - Ok(match value { - x if x == Self::Ipv4 as u16 => Self::Ipv4, - x if x == Self::Ipv6 as u16 => Self::Ipv6, - other => bail!("Unknown AddressFamily: {}", other), - }) - } -} - -impl Display for AddressFamilyId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl IpVersion { + /// How many bits an address of this version has. + pub fn bits(self) -> u8 { match self { - AddressFamilyId::Ipv4 => write!(f, "Ipv4"), - AddressFamilyId::Ipv6 => write!(f, "Ipv6"), + Self::Ipv4 => 32, + Self::Ipv6 => 128, } } } -/// Represents a Subsequent Address Family Identifier. -#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, Serialize_repr, Deserialize_repr)] -#[repr(u8)] -pub enum SubsequentAfi { - Unicast = 1, - Multicast = 2, - NlriWithMpls = 4, - MplsLabelledVpn = 128, - MulticastMplsVpn = 129, +impl TryFrom for IpVersion { + type Error = UnknownAfi; + + fn try_from(value: u16) -> Result { + match value { + x if x == Self::Ipv4 as u16 => Ok(Self::Ipv4), + x if x == Self::Ipv6 as u16 => Ok(Self::Ipv6), + other => Err(UnknownAfi(other)), + } + } } -impl TryFrom for SubsequentAfi { - type Error = eyre::Error; +impl Display for IpVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IpVersion::Ipv4 => write!(f, "Ipv4"), + IpVersion::Ipv6 => write!(f, "Ipv6"), + } + } +} - fn try_from(value: u8) -> Result { - Ok(match value { - x if x == Self::Unicast as u8 => Self::Unicast, - x if x == Self::Multicast as u8 => Self::Multicast, - x if x == Self::NlriWithMpls as u8 => Self::NlriWithMpls, - x if x == Self::MplsLabelledVpn as u8 => Self::MplsLabelledVpn, - x if x == Self::MulticastMplsVpn as u8 => Self::MulticastMplsVpn, - other => bail!("Unknown SubsequentAfi: {}", other), +/// An Address Family Identifier as it appears on the wire (RFC 4760). +/// +/// Any number is a valid value: a peer may name a family this implementation +/// has never heard of, and that must be readable so it can be ignored. Which +/// families are *supported* is a separate question, answered where routes +/// are stored. +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct Afi(pub u16); + +impl Afi { + pub const IPV4: Self = Self(1); + pub const IPV6: Self = Self(2); + pub const L2VPN: Self = Self(25); + pub const BGP_LS: Self = Self(16388); +} + +impl From for Afi { + fn from(version: IpVersion) -> Self { + Self(version as u16) + } +} + +impl TryFrom for IpVersion { + type Error = UnknownAfi; + + fn try_from(afi: Afi) -> Result { + Self::try_from(afi.0) + } +} + +/// A Subsequent Address Family Identifier as it appears on the wire. Like +/// [`Afi`], any number is a valid value. +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct Safi(pub u8); + +impl Safi { + pub const UNICAST: Self = Self(1); + pub const MULTICAST: Self = Self(2); + pub const MPLS_LABEL: Self = Self(4); + pub const EVPN: Self = Self(70); + pub const BGP_LS: Self = Self(71); + pub const MPLS_VPN: Self = Self(128); + pub const MULTICAST_MPLS_VPN: Self = Self(129); + pub const FLOWSPEC: Self = Self(133); +} + +/// An address family: the pair that says what kind of routes a capability +/// or an MP_REACH_NLRI is about. +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +pub struct AfiSafi { + pub afi: Afi, + pub safi: Safi, +} + +impl AfiSafi { + pub const IPV4_UNICAST: Self = Self::new(Afi::IPV4, Safi::UNICAST); + pub const IPV6_UNICAST: Self = Self::new(Afi::IPV6, Safi::UNICAST); + + pub const fn new(afi: Afi, safi: Safi) -> Self { + Self { afi, safi } + } + + /// The unicast family of an IP version. + pub fn unicast(version: IpVersion) -> Self { + Self::new(version.into(), Safi::UNICAST) + } + + /// The usual name of the family, if it has one here. + fn name(self) -> Option<&'static str> { + Some(match (self.afi, self.safi) { + (Afi::IPV4, Safi::UNICAST) => "IPv4 unicast", + (Afi::IPV6, Safi::UNICAST) => "IPv6 unicast", + (Afi::IPV4, Safi::MULTICAST) => "IPv4 multicast", + (Afi::IPV6, Safi::MULTICAST) => "IPv6 multicast", + (Afi::IPV4, Safi::MPLS_LABEL) => "IPv4 labeled unicast", + (Afi::IPV6, Safi::MPLS_LABEL) => "IPv6 labeled unicast", + (Afi::IPV4, Safi::MPLS_VPN) => "VPNv4 unicast", + (Afi::IPV6, Safi::MPLS_VPN) => "VPNv6 unicast", + (Afi::IPV4, Safi::FLOWSPEC) => "IPv4 flowspec", + (Afi::IPV6, Safi::FLOWSPEC) => "IPv6 flowspec", + (Afi::L2VPN, Safi::EVPN) => "L2VPN EVPN", + (Afi::BGP_LS, Safi::BGP_LS) => "BGP-LS", + _ => return None, }) } } -/// The implementation try_from for u16 just reuses the u8 implementation since -/// we don't have any cases where the SAFI is longer than u8::MAX yet. -impl TryFrom for SubsequentAfi { - type Error = eyre::Error; - - fn try_from(value: u16) -> Result { - TryFrom::::try_from(value as u8) - } -} - -impl Display for SubsequentAfi { +impl Display for AfiSafi { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - SubsequentAfi::Unicast => write!(f, "Unicast"), - SubsequentAfi::Multicast => write!(f, "Multicast"), - SubsequentAfi::NlriWithMpls => write!(f, "NlriWithMpls"), - SubsequentAfi::MplsLabelledVpn => write!(f, "MplsLabelledVpn"), - SubsequentAfi::MulticastMplsVpn => write!(f, "MulticastMplsVpn"), + match self.name() { + Some(name) => f.write_str(name), + None => write!(f, "AFI {} SAFI {}", self.afi.0, self.safi.0), } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn any_family_is_a_value_and_the_common_ones_have_names() { + assert_eq!(AfiSafi::IPV6_UNICAST.to_string(), "IPv6 unicast"); + assert_eq!( + AfiSafi::new(Afi::L2VPN, Safi::EVPN).to_string(), + "L2VPN EVPN" + ); + assert_eq!( + AfiSafi::new(Afi(999), Safi(7)).to_string(), + "AFI 999 SAFI 7" + ); + assert_eq!(AfiSafi::unicast(IpVersion::Ipv4), AfiSafi::IPV4_UNICAST); + } + + #[test] + fn only_ipv4_and_ipv6_are_ip_versions() { + assert_eq!(IpVersion::try_from(Afi::IPV6), Ok(IpVersion::Ipv6)); + assert_eq!(IpVersion::try_from(Afi::L2VPN), Err(UnknownAfi(25))); + assert_eq!(IpVersion::try_from(3u16), Err(UnknownAfi(3))); + } +} diff --git a/crates/primitives/src/ip_prefix.rs b/crates/primitives/src/ip_prefix.rs index 8e8dc09..8080a1f 100644 --- a/crates/primitives/src/ip_prefix.rs +++ b/crates/primitives/src/ip_prefix.rs @@ -2,130 +2,187 @@ use std::fmt::Display; use std::net::{Ipv4Addr, Ipv6Addr}; use std::str::FromStr; -use bytes::{Buf, BufMut, BytesMut}; -use eyre::{Result, bail}; -use serde::{Deserialize, Serialize}; +use serde::Serialize; +use thiserror::Error; -use crate::constants::AddressFamilyId; -use crate::parser::{ParserContext, ToWireError}; +use crate::constants::IpVersion; -/// IpPrefix represents some IP address prefix, for a specific AddressFamilyId. -#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)] +/// Why something is not a valid prefix. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PrefixError { + #[error("prefix {0:?} is not in address/length form")] + Format(String), + #[error("prefix {0:?} has an address that is neither IPv4 nor IPv6")] + Address(String), + #[error("prefix length {length} is longer than the {max} bits of an address")] + LengthTooLong { length: u8, max: u8 }, + #[error("prefix length {length} needs {needed} address bytes, got {got}")] + TooFewBytes { + length: u8, + needed: usize, + got: usize, + }, +} + +/// An IPv4 or IPv6 prefix. +/// +/// It is a small value with no allocation behind it, cheap to copy, compare +/// and hash: a routing table holds millions of them and looks them up by +/// value. A prefix has one representation: the bits past its length are +/// always zero, so two prefixes are equal exactly when they are the same +/// prefix. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize)] pub struct IpPrefix { - pub address_family: AddressFamilyId, - pub prefix: Vec, - pub length: u8, + address_family: IpVersion, + length: u8, + /// The address, left-aligned, zero past `length` bits. + address: [u8; 16], } impl IpPrefix { - pub fn new(address_family: AddressFamilyId, prefix: Vec, length: u8) -> Result { - // Ensure that the prefix we are given contains the right number of bytes corresponding to the prefix length. - if prefix.len() < ((length + 7) / 8).into() { - bail!( - "Mismatched prefix {:?} for given prefix length: {}", - prefix, - length - ); + /// Makes a prefix from the leading bytes of its address. `prefix` must + /// hold at least the bytes that `length` bits cover; anything past + /// those bits is ignored (RFC 4271 §4.3: such bits are irrelevant). + pub fn new( + address_family: IpVersion, + prefix: impl AsRef<[u8]>, + length: u8, + ) -> Result { + let prefix = prefix.as_ref(); + let max = address_family.bits(); + if length > max { + return Err(PrefixError::LengthTooLong { length, max }); + } + let needed = usize::from(length.div_ceil(8)); + if prefix.len() < needed { + return Err(PrefixError::TooFewBytes { + length, + needed, + got: prefix.len(), + }); + } + let mut address = [0u8; 16]; + address[..needed].copy_from_slice(&prefix[..needed]); + if let (Some(last), rem @ 1..) = (address[..needed].last_mut(), length % 8) { + *last &= u8::MAX << (8 - rem); } Ok(Self { address_family, - prefix, length, + address, }) } - pub fn to_wire(&self, _: &ParserContext, out: &mut BytesMut) -> Result<(), ToWireError> { - // Verify that there is enough space to write the IpPrefix. - if out.remaining() < (self.prefix.len() + 1) { - Err(ToWireError::OutBufferOverflow)?; + pub fn address_family(&self) -> IpVersion { + self.address_family + } + + /// The prefix length in bits. + pub fn length(&self) -> u8 { + self.length + } + + /// The bytes the prefix length covers, as they go on the wire: none for + /// a default route, three for a /24. + pub fn bytes(&self) -> &[u8] { + &self.address[..usize::from(self.length.div_ceil(8))] + } + + /// Returns true if `other` is equal to or more specific than `self`, i.e. + /// `self` covers `other`. Prefixes of different address families never + /// contain each other. + pub fn contains(&self, other: &IpPrefix) -> bool { + if self.address_family != other.address_family || other.length < self.length { + return false; } + let full_bytes = usize::from(self.length / 8); + if self.address[..full_bytes] != other.address[..full_bytes] { + return false; + } + let rem_bits = self.length % 8; + if rem_bits == 0 { + return true; + } + let mask = u8::MAX << (8 - rem_bits); + (self.address[full_bytes] & mask) == (other.address[full_bytes] & mask) + } +} - // Write length and prefix. - out.put_u8(self.length); - out.put(self.prefix.as_slice()); +/// A prefix as an UPDATE names it: the prefix and, on a session where +/// ADD-PATH is in use for the address family (RFC 7911), the identifier +/// that tells apart the several routes a peer may announce for it. +/// +/// Whether the identifier is on the wire is a matter of what the session +/// negotiated, not of its value. Where it is not, it is zero here. +#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash, Serialize)] +pub struct Nlri { + pub prefix: IpPrefix, + pub path_id: u32, +} - Ok(()) +impl Nlri { + /// `prefixes` as they are named on a session without ADD-PATH. + pub fn plain(prefixes: impl IntoIterator) -> Vec { + prefixes.into_iter().map(Self::from).collect() + } + + /// The bytes this takes on the wire, with or without its identifier. + pub fn wire_len(&self, with_path_id: bool) -> usize { + 1 + self.prefix.bytes().len() + if with_path_id { 4 } else { 0 } + } +} + +impl From for Nlri { + fn from(prefix: IpPrefix) -> Self { + Self { prefix, path_id: 0 } + } +} + +/// So that a list of these compares with a list of plain prefixes. +impl PartialEq for Nlri { + fn eq(&self, prefix: &IpPrefix) -> bool { + self.path_id == 0 && self.prefix == *prefix + } +} + +impl Display for Nlri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.path_id { + 0 => write!(f, "{}", self.prefix), + id => write!(f, "{} (path {id})", self.prefix), + } } } impl TryFrom<&str> for IpPrefix { - type Error = eyre::Error; + type Error = PrefixError; fn try_from(value: &str) -> Result { - let parts: Vec<&str> = value.split("/").collect(); + let format = || PrefixError::Format(value.to_string()); + let address = || PrefixError::Address(value.to_string()); + let (addr, length) = value.split_once('/').ok_or_else(format)?; + let length: u8 = length.parse().map_err(|_| format())?; - if parts.len() != 2 { - bail!( - "Expected IpPrefix in format prefix/length but got: {}", - value - ); - } - - let length: u8 = u8::from_str_radix(parts[1], 10).map_err(eyre::Error::from)?; - let mut octets; - let afi: AddressFamilyId; - - if parts[0].contains(":") { - afi = AddressFamilyId::Ipv6; - let addr: Ipv6Addr = Ipv6Addr::from_str(parts[0]).map_err(eyre::Error::from)?; - octets = addr.octets().to_vec(); - } else if parts[0].contains(".") { - afi = AddressFamilyId::Ipv4; - let addr: Ipv4Addr = Ipv4Addr::from_str(parts[0]).map_err(eyre::Error::from)?; - octets = addr.octets().to_vec(); + let (afi, octets) = if addr.contains(':') { + let addr = Ipv6Addr::from_str(addr).map_err(|_| address())?; + (IpVersion::Ipv6, addr.octets().to_vec()) } else { - bail!("Could not figure out address type") - } - - // Truncate the octets we have to the right number of bytes to match the prefix length.. - if length % 8 == 0 { - // We can cleanly truncate the number of bytes since we are at a byte boundary. - octets.truncate((length / 8).into()); - } else { - // We need to keep length % 8 bits of the last byte. - let num_bytes = (length / 8) + 1; - let mask = u8::MAX << (8 - (length % 8)); - octets.truncate(num_bytes.into()); - // Fix up the last byte. - let last_pos = octets.len() - 1; - octets[last_pos] &= mask; - } - - Ok(IpPrefix { - address_family: afi, - prefix: octets, - length, - }) + let addr = Ipv4Addr::from_str(addr).map_err(|_| address())?; + (IpVersion::Ipv4, addr.octets().to_vec()) + }; + IpPrefix::new(afi, octets, length) } } impl Display for IpPrefix { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self.address_family { - AddressFamilyId::Ipv4 => { - // Pad the prefix with 0 bytes if it's less than 4 bytes long. - let bytes = &mut self.prefix.clone(); - if bytes.len() < 4 { - bytes.extend(std::iter::repeat(0).take(4 - bytes.len())); - } - let four_bytes: [u8; 4] = bytes - .as_slice() - .try_into() - .map_err(|_| std::fmt::Error {})?; - let ipv4_addr = Ipv4Addr::from(four_bytes); - write!(f, "{}/{}", ipv4_addr, self.length) + IpVersion::Ipv4 => { + let [a, b, c, d, ..] = self.address; + write!(f, "{}/{}", Ipv4Addr::new(a, b, c, d), self.length) } - AddressFamilyId::Ipv6 => { - let bytes = &mut self.prefix.clone(); - if bytes.len() < 16 { - bytes.extend(std::iter::repeat(0).take(16 - bytes.len())); - } - let sixteen_bytes: [u8; 16] = bytes - .as_slice() - .try_into() - .map_err(|_| std::fmt::Error {})?; - let ipv6_addr = Ipv6Addr::from(sixteen_bytes); - write!(f, "{}/{}", ipv6_addr, self.length) + IpVersion::Ipv6 => { + write!(f, "{}/{}", Ipv6Addr::from(self.address), self.length) } } } @@ -133,9 +190,11 @@ impl Display for IpPrefix { #[cfg(test)] mod tests { - use crate::constants::AddressFamilyId; + use crate::constants::IpVersion; - use super::IpPrefix; + use proptest::prelude::*; + + use super::{IpPrefix, PrefixError}; macro_rules! verify_roundtrip { ($name:ident, $prefix_str:expr, $afi:expr, $bytes:expr, $length:expr) => { @@ -143,9 +202,9 @@ mod tests { fn $name() { let ip_prefix = IpPrefix::try_from($prefix_str).unwrap(); - assert_eq!(ip_prefix.address_family, $afi); - assert_eq!(ip_prefix.prefix, $bytes); - assert_eq!(ip_prefix.length, $length); + assert_eq!(ip_prefix.address_family(), $afi); + assert_eq!(ip_prefix.bytes(), $bytes); + assert_eq!(ip_prefix.length(), $length); let to_str: &str = &ip_prefix.to_string(); assert_eq!(IpPrefix::try_from(to_str).unwrap(), ip_prefix); @@ -156,7 +215,7 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv4_24, "10.1.2.0/24", - AddressFamilyId::Ipv4, + IpVersion::Ipv4, vec![10, 1, 2], 24 ); @@ -165,7 +224,7 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv4_19, "10.245.123.0/19", - AddressFamilyId::Ipv4, + IpVersion::Ipv4, vec![10, 245, 96], 19 ); @@ -174,7 +233,7 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv4_3, "192.168.1.0/3", - AddressFamilyId::Ipv4, + IpVersion::Ipv4, vec![192], 3 ); @@ -183,7 +242,7 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv4_0, "0.0.0.0/0", - AddressFamilyId::Ipv4, + IpVersion::Ipv4, vec![], 0 ); @@ -191,7 +250,7 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv6_48, "2001:db8:cafe::/48", - AddressFamilyId::Ipv6, + IpVersion::Ipv6, vec![32, 1, 13, 184, 202, 254], 48 ); @@ -200,16 +259,120 @@ mod tests { verify_roundtrip!( verify_roundtrip_ipv6_32, "2001:db8:cafe::/32", - AddressFamilyId::Ipv6, + IpVersion::Ipv6, vec![32, 1, 13, 184], 32 ); - verify_roundtrip!( - verify_roundtrip_ipv6_0, - "::/0", - AddressFamilyId::Ipv6, - vec![], - 0 - ); + #[test] + fn text_that_is_not_a_prefix_is_rejected_with_the_reason() { + for (text, expected) in [ + ("10.0.0.0", PrefixError::Format("10.0.0.0".into())), + ("10.0.0.0/x", PrefixError::Format("10.0.0.0/x".into())), + ("10.0.0/8", PrefixError::Address("10.0.0/8".into())), + ("nonsense/8", PrefixError::Address("nonsense/8".into())), + ( + "10.0.0.0/33", + PrefixError::LengthTooLong { + length: 33, + max: 32, + }, + ), + ( + "2001:db8::/129", + PrefixError::LengthTooLong { + length: 129, + max: 128, + }, + ), + ] { + assert_eq!(IpPrefix::try_from(text), Err(expected), "{text}"); + } + assert!(IpPrefix::try_from("0.0.0.0/0").is_ok()); + assert!(IpPrefix::try_from("2001:db8::1/128").is_ok()); + assert_eq!( + IpPrefix::new(IpVersion::Ipv4, vec![10], 16), + Err(PrefixError::TooFewBytes { + length: 16, + needed: 2, + got: 1 + }) + ); + } + + #[test] + fn a_prefix_has_one_representation() { + // Extra bytes and stray bits past the length are dropped. + let noisy = IpPrefix::new(IpVersion::Ipv4, vec![10, 0xff, 3, 4], 12).unwrap(); + assert_eq!(noisy, IpPrefix::try_from("10.240.0.0/12").unwrap()); + assert_eq!(noisy.bytes(), [10, 0xf0]); + assert_eq!( + IpPrefix::new(IpVersion::Ipv4, vec![0xff; 13], 99), + Err(PrefixError::LengthTooLong { + length: 99, + max: 32 + }) + ); + } + + #[test] + fn a_prefix_is_a_small_value() { + assert!(size_of::() <= 20); + let prefix = IpPrefix::try_from("10.1.2.0/24").unwrap(); + let copy = prefix; + assert_eq!(copy, prefix); + assert_eq!(prefix.bytes(), [10, 1, 2]); + assert_eq!(IpPrefix::try_from("0.0.0.0/0").unwrap().bytes(), [0u8; 0]); + } + + proptest! { + #[test] + fn text_round_trips(version: bool, bytes: [u8; 16], length in 0u8..=128) { + let version = if version { IpVersion::Ipv6 } else { IpVersion::Ipv4 }; + let length = length.min(version.bits()); + let prefix = IpPrefix::new(version, bytes, length).unwrap(); + let text = prefix.to_string(); + prop_assert_eq!(IpPrefix::try_from(text.as_str()), Ok(prefix)); + prop_assert!(prefix.contains(&prefix)); + } + + #[test] + fn text_that_is_noise_is_an_error_or_a_prefix_that_round_trips(text in "\\PC{0,40}") { + if let Ok(prefix) = IpPrefix::try_from(text.as_str()) { + let shown = prefix.to_string(); + prop_assert_eq!(IpPrefix::try_from(shown.as_str()), Ok(prefix)); + } + } + + /// `contains` agrees with comparing the leading bits by hand. + #[test] + fn contains_is_a_comparison_of_leading_bits( + a: [u8; 4], a_len in 0u8..=32, b: [u8; 4], b_len in 0u8..=32, + ) { + let outer = IpPrefix::new(IpVersion::Ipv4, a, a_len).unwrap(); + let inner = IpPrefix::new(IpVersion::Ipv4, b, b_len).unwrap(); + let mask = if a_len == 0 { 0 } else { u32::MAX << (32 - a_len) }; + let expected = b_len >= a_len + && (u32::from_be_bytes(a) & mask) == (u32::from_be_bytes(b) & mask); + prop_assert_eq!(outer.contains(&inner), expected); + } + } + + #[test] + fn contains_covers_more_specifics() { + let p = |s: &str| IpPrefix::try_from(s).unwrap(); + assert!(p("10.100.0.0/16").contains(&p("10.100.7.0/24"))); + assert!(p("10.100.0.0/16").contains(&p("10.100.0.0/16"))); + assert!(!p("10.100.0.0/16").contains(&p("10.101.0.0/24"))); + assert!(!p("10.100.7.0/24").contains(&p("10.100.0.0/16"))); + assert!(p("0.0.0.0/0").contains(&p("192.0.2.0/24"))); + // Non-byte-aligned boundary. + assert!(p("10.100.0.0/19").contains(&p("10.100.31.0/24"))); + assert!(!p("10.100.0.0/19").contains(&p("10.100.32.0/24"))); + // Cross-family never matches. + assert!(!p("::/0").contains(&p("10.0.0.0/8"))); + assert!(p("2001:db8::/32").contains(&p("2001:db8:cafe::/48"))); + } + + verify_roundtrip!(verify_roundtrip_ipv6_0, "::/0", IpVersion::Ipv6, vec![], 0); } diff --git a/crates/primitives/src/parser.rs b/crates/primitives/src/parser.rs index d1cca4c..5c849f5 100644 --- a/crates/primitives/src/parser.rs +++ b/crates/primitives/src/parser.rs @@ -1,35 +1,43 @@ -use std::fmt::Display; - use nom::error::{ErrorKind, ParseError}; -use crate::constants::AddressFamilyId; +use crate::constants::AfiSafi; +use thiserror::Error; -#[derive(Debug, Default)] +/// What reading or writing a message has to know about the session it +/// belongs to. The default is a session before its OPENs are exchanged; +/// only an UPDATE depends on anything here, and there are none until then. +#[derive(Debug, Clone, Default)] pub struct ParserContext { - /// Whether thi parser is being run with a peer that is RFC6793 compliant. - pub four_octet_asn: Option, - /// Which address family should be parsed by default with this parser. - pub address_family: Option, + /// Whether AS numbers are four octets wide on this session (RFC 6793). + pub four_octet_asn: bool, + /// Whether the peer is in our own AS. Some attributes mean something + /// only between internal peers and are dropped when an external one + /// sends them (RFC 7606 §7). + pub ibgp: bool, + /// The address families whose prefixes carry a path identifier in the + /// direction this context is for (RFC 7911). What a session sends and + /// what it receives are negotiated separately, so a session has one + /// context for reading and one for writing. + pub add_path: Vec, } -#[derive(Debug)] -pub enum ToWireError { - /// There was not enough space in the output buffer to serialize the data into. - OutBufferOverflow, - /// Another error. - Other(eyre::Error), -} - -impl Display for ToWireError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ToWireError::OutBufferOverflow => write!(f, "OutBufferOverflow"), - ToWireError::Other(report) => report.fmt(f), - } +impl ParserContext { + pub fn has_add_path(&self, family: AfiSafi) -> bool { + self.add_path.contains(&family) } } -impl std::error::Error for ToWireError {} +/// Why a value could not be written to the wire. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EncodeError { + /// A length does not fit the field that has to carry it. + #[error("{what} is too long to encode: {len} exceeds {max}")] + TooLong { + what: &'static str, + len: usize, + max: usize, + }, +} // Custom error type for the parser. #[derive(Debug)] @@ -37,8 +45,8 @@ pub enum BgpParserError { /// ProtocolError encapsulates protocol level semantics problems that require /// action from the caller of the parser. ProtocolError(ProtocolErrorValues), - CustomText(&'static str), - Eyre(eyre::ErrReport), + /// The input does not follow the format; the text says how. + Malformed(String), Nom(I, ErrorKind), } diff --git a/crates/primitives/src/poptrie/mod.rs b/crates/primitives/src/poptrie/mod.rs index e8cead5..b53623e 100644 --- a/crates/primitives/src/poptrie/mod.rs +++ b/crates/primitives/src/poptrie/mod.rs @@ -1,3 +1,10 @@ +//! A Poptrie (Asai and Ohara, SIGCOMM 2015): a compressed multiway trie for +//! longest-prefix matching. +//! +//! Work in progress. Nothing in the daemon uses it yet, and the construction +//! side has no public entry point, hence the lint expectation below. +#![expect(dead_code, reason = "not yet wired into a RIB")] + use std::collections::BTreeMap; #[derive(Debug)] @@ -75,7 +82,8 @@ impl Poptrie { result } - fn new(&mut self, rt: BTreeMap<(u32, u8), T>) { + /// Fills the trie from a routing table keyed by (address, prefix length). + fn build(&mut self, rt: BTreeMap<(u32, u8), T>) { const DEPTH: u8 = 6; let mut forest = vec![(0, rt)]; @@ -168,9 +176,10 @@ mod tests { leaves: vec![], }; - poptrie.new(map); - println!("Success"); - - dbg!(poptrie); + poptrie.build(map); + assert!( + !poptrie.inners.is_empty(), + "building from a non-empty table yields nodes" + ); } } diff --git a/crates/rib/Cargo.toml b/crates/rib/Cargo.toml index 1b4a644..ce39351 100644 --- a/crates/rib/Cargo.toml +++ b/crates/rib/Cargo.toml @@ -1,9 +1,25 @@ [package] name = "bgp-rib" +authors.workspace = true edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true [dependencies] bgp-packet.workspace = true bgp-primitives.workspace = true +bytes.workspace = true +foldhash.workspace = true +hashbrown.workspace = true +serde.workspace = true +thiserror.workspace = true tokio = { workspace = true, features = ["full"] } tracing.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/rib/proptest-regressions/table/journal_tests.txt b/crates/rib/proptest-regressions/table/journal_tests.txt new file mode 100644 index 0000000..7ee2a27 --- /dev/null +++ b/crates/rib/proptest-regressions/table/journal_tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7107cf5580fe7bb95e138f1fead386d89273532f83aa1d11808aa9586aa9da63 # shrinks to ops = [Withdraw { peer: 0, prefix: 1 }, Withdraw { peer: 0, prefix: 1 }, Poll { reader: 2, budget: 2 }, Withdraw { peer: 0, prefix: 10 }, Poll { reader: 2, budget: 4 }, Announce { peer: 2, prefix: 7, route: Route { local_pref: None, path: [1, 2], origin: 0, med: Some(0) } }, Announce { peer: 3, prefix: 6, route: Route { local_pref: None, path: [1], origin: 0, med: None } }, Announce { peer: 3, prefix: 6, route: Route { local_pref: None, path: [1, 1], origin: 0, med: None } }, Withdraw { peer: 1, prefix: 5 }, Withdraw { peer: 0, prefix: 11 }, Withdraw { peer: 3, prefix: 6 }, Announce { peer: 0, prefix: 3, route: Route { local_pref: None, path: [1, 2], origin: 1, med: None } }, Poll { reader: 2, budget: 3 }, Withdraw { peer: 0, prefix: 8 }, Flap { peer: 3 }, Withdraw { peer: 1, prefix: 1 }, Announce { peer: 2, prefix: 5, route: Route { local_pref: None, path: [1], origin: 1, med: Some(0) } }, Announce { peer: 0, prefix: 1, route: Route { local_pref: None, path: [1, 1], origin: 1, med: None } }, Withdraw { peer: 0, prefix: 1 }, Withdraw { peer: 2, prefix: 8 }, Poll { reader: 1, budget: 2 }, Flap { peer: 1 }, Poll { reader: 2, budget: 2 }, Announce { peer: 0, prefix: 9, route: Route { local_pref: None, path: [1], origin: 0, med: Some(1) } }, Announce { peer: 2, prefix: 2, route: Route { local_pref: Some(100), path: [2], origin: 0, med: Some(1) } }, Poll { reader: 1, budget: 3 }, Withdraw { peer: 3, prefix: 1 }, Announce { peer: 1, prefix: 4, route: Route { local_pref: None, path: [2], origin: 1, med: None } }, Announce { peer: 3, prefix: 7, route: Route { local_pref: None, path: [2], origin: 0, med: None } }, Announce { peer: 1, prefix: 11, route: Route { local_pref: None, path: [1], origin: 1, med: Some(1) } }, Withdraw { peer: 0, prefix: 6 }, Withdraw { peer: 0, prefix: 4 }, Announce { peer: 3, prefix: 10, route: Route { local_pref: Some(100), path: [2, 2], origin: 1, med: None } }, Announce { peer: 1, prefix: 2, route: Route { local_pref: None, path: [2], origin: 0, med: Some(2) } }, Announce { peer: 1, prefix: 3, route: Route { local_pref: None, path: [1], origin: 1, med: None } }, Withdraw { peer: 0, prefix: 2 }, Announce { peer: 1, prefix: 4, route: Route { local_pref: None, path: [1, 1], origin: 0, med: None } }, Poll { reader: 1, budget: 2 }, Poll { reader: 1, budget: 4 }, Poll { reader: 2, budget: 1 }, Poll { reader: 1, budget: 3 }, Poll { reader: 1, budget: 5 }, Poll { reader: 1, budget: 1 }, Withdraw { peer: 3, prefix: 5 }, Withdraw { peer: 3, prefix: 9 }, Withdraw { peer: 2, prefix: 10 }], shards = 3 diff --git a/crates/rib/proptest-regressions/table/tests.txt b/crates/rib/proptest-regressions/table/tests.txt new file mode 100644 index 0000000..160a710 --- /dev/null +++ b/crates/rib/proptest-regressions/table/tests.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 6bcfc317c976928c2fbb29e943f385127b91770a76f18e7061b80b31f4276955 # shrinks to ops = [Announce { peer: 1, prefix: 1, route: Route { local_pref: None, path: [2], origin: 0, med: None } }, Announce { peer: 2, prefix: 1, route: Route { local_pref: None, path: [1], origin: 0, med: Some(2) } }, Announce { peer: 3, prefix: 1, route: Route { local_pref: None, path: [1], origin: 0, med: None } }, Announce { peer: 3, prefix: 1, route: Route { local_pref: None, path: [1], origin: 1, med: None } }] diff --git a/crates/rib/src/adj_rib_in.rs b/crates/rib/src/adj_rib_in.rs deleted file mode 100644 index 4f7cf53..0000000 --- a/crates/rib/src/adj_rib_in.rs +++ /dev/null @@ -1,96 +0,0 @@ -use std::collections::HashMap; - -use bgp_primitives::ip_prefix::IpPrefix; -use tokio::sync::{broadcast, mpsc, oneshot}; -use tracing::warn; - -use crate::entry::{PeerId, RibEntry, SnapshotEntry}; -use crate::event::RibEvent; - -const EVENT_CHANNEL_CAPACITY: usize = 1024; -const CMD_CHANNEL_CAPACITY: usize = 256; - -enum RibCommand { - Announce { peer: PeerId, prefix: IpPrefix, entry: RibEntry }, - Withdraw { peer: PeerId, prefix: IpPrefix }, - Snapshot { reply: oneshot::Sender> }, -} - -/// Handle to the Adj-RIB-In actor. Cheap to clone — all clones share the same -/// backing task. The actor is stopped when all handles are dropped. -#[derive(Clone)] -pub struct AdjRibIn { - tx: mpsc::Sender, - events: broadcast::Sender, -} - -impl AdjRibIn { - /// Spawns the backing actor and returns a handle. - pub fn spawn() -> Self { - let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); - let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); - let events_clone = events.clone(); - tokio::spawn(run_actor(rx, events_clone)); - Self { tx, events } - } - - pub async fn announce(&self, peer: PeerId, prefix: IpPrefix, entry: RibEntry) { - let _ = self.tx.send(RibCommand::Announce { peer, prefix, entry }).await; - } - - pub async fn withdraw(&self, peer: PeerId, prefix: IpPrefix) { - let _ = self.tx.send(RibCommand::Withdraw { peer, prefix }).await; - } - - /// Returns a point-in-time snapshot of all routes in the RIB. - pub async fn snapshot(&self) -> Vec { - let (reply_tx, reply_rx) = oneshot::channel(); - let _ = self.tx.send(RibCommand::Snapshot { reply: reply_tx }).await; - reply_rx.await.unwrap_or_default() - } - - /// Subscribe to a stream of RIB mutation events. - /// - /// The receiver will see every [`RibEvent`] from the point of subscription. - /// If the consumer falls behind by more than [`EVENT_CHANNEL_CAPACITY`] - /// events it will receive a [`broadcast::error::RecvError::Lagged`] error. - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() - } -} - -async fn run_actor(mut rx: mpsc::Receiver, events: broadcast::Sender) { - let mut table: HashMap<(PeerId, IpPrefix), RibEntry> = HashMap::new(); - - while let Some(cmd) = rx.recv().await { - match cmd { - RibCommand::Announce { peer, prefix, entry } => { - table.insert((peer, prefix.clone()), entry.clone()); - if let Err(e) = events.send(RibEvent::Announced { peer, prefix, entry }) { - // No active subscribers — not an error. - let _ = e; - } - } - RibCommand::Withdraw { peer, prefix } => { - if table.remove(&(peer, prefix.clone())).is_some() { - if let Err(e) = events.send(RibEvent::Withdrawn { peer, prefix }) { - let _ = e; - } - } - } - RibCommand::Snapshot { reply } => { - let snap = table - .iter() - .map(|((peer, prefix), entry)| SnapshotEntry { - peer: *peer, - prefix: prefix.clone(), - entry: entry.clone(), - }) - .collect(); - if reply.send(snap).is_err() { - warn!("Snapshot reply channel closed before send"); - } - } - } - } -} diff --git a/crates/rib/src/entry.rs b/crates/rib/src/entry.rs deleted file mode 100644 index bdf278e..0000000 --- a/crates/rib/src/entry.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::net::SocketAddr; -use std::time::SystemTime; - -use bgp_packet::message::PathAttribute; -use bgp_primitives::ip_prefix::IpPrefix; - -/// Identifies a BGP peer by its TCP address and negotiated ASN. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct PeerId { - pub addr: SocketAddr, - pub asn: u32, -} - -impl std::fmt::Display for PeerId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}(AS{})", self.addr, self.asn) - } -} - -/// A single route stored in the Adj-RIB-In. -#[derive(Debug, Clone)] -pub struct RibEntry { - pub path_attributes: Vec, - pub received_at: SystemTime, -} - -impl RibEntry { - pub fn new(path_attributes: Vec) -> Self { - Self { path_attributes, received_at: SystemTime::now() } - } -} - -/// A flattened row returned by [`crate::AdjRibIn::snapshot`]. -#[derive(Debug, Clone)] -pub struct SnapshotEntry { - pub peer: PeerId, - pub prefix: IpPrefix, - pub entry: RibEntry, -} diff --git a/crates/rib/src/error.rs b/crates/rib/src/error.rs new file mode 100644 index 0000000..9cc68f2 --- /dev/null +++ b/crates/rib/src/error.rs @@ -0,0 +1,14 @@ +/// A RIB's actor is no longer running, so the table can be neither read nor +/// changed. +/// +/// The actor runs for as long as a handle to it exists; it stops early only +/// by panicking. That is a bug, and one the server cannot work around: it +/// could no longer say what it originates. Callers pass this up +/// until the process exits and is restarted, rather than carry on with a +/// table that looks empty. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("the {rib} has stopped")] +pub struct RibStopped { + /// Which table. + pub rib: &'static str, +} diff --git a/crates/rib/src/event.rs b/crates/rib/src/event.rs deleted file mode 100644 index 868afdd..0000000 --- a/crates/rib/src/event.rs +++ /dev/null @@ -1,9 +0,0 @@ -use bgp_primitives::ip_prefix::IpPrefix; - -use crate::entry::{PeerId, RibEntry}; - -#[derive(Debug, Clone)] -pub enum RibEvent { - Announced { peer: PeerId, prefix: IpPrefix, entry: RibEntry }, - Withdrawn { peer: PeerId, prefix: IpPrefix }, -} diff --git a/crates/rib/src/lib.rs b/crates/rib/src/lib.rs index 8bb39e6..dc215a3 100644 --- a/crates/rib/src/lib.rs +++ b/crates/rib/src/lib.rs @@ -1,7 +1,9 @@ -mod adj_rib_in; -mod entry; -mod event; +//! The router's tables: the routes it has learned and chosen +//! ([`table::RibTable`]) and the routes it originates ([`LocalRib`]). -pub use adj_rib_in::AdjRibIn; -pub use entry::{PeerId, RibEntry, SnapshotEntry}; -pub use event::RibEvent; +mod error; +mod local_rib; +pub mod table; + +pub use error::RibStopped; +pub use local_rib::{LocalRib, LocalRibEvent, LocalRoute, LocalRouteAttrs}; diff --git a/crates/rib/src/local_rib.rs b/crates/rib/src/local_rib.rs new file mode 100644 index 0000000..8385352 --- /dev/null +++ b/crates/rib/src/local_rib.rs @@ -0,0 +1,262 @@ +//! Locally originated routes (the "Loc-RIB" of routes this router announces +//! on its own behalf, as opposed to routes learned from peers). +//! +//! The actor stores one route per prefix together with its attributes. It +//! does not know about peers or sessions: the server subscribes to +//! [`LocalRibEvent`]s and turns them into UPDATEs. + +use std::collections::HashMap; +use std::time::SystemTime; + +use bgp_packet::message::{LargeCommunity, OriginPathAttribute}; +use bgp_primitives::ip_prefix::IpPrefix; +use serde::Serialize; +use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::task::AbortHandle; + +use crate::error::RibStopped; + +const EVENT_CHANNEL_CAPACITY: usize = 1024; +const CMD_CHANNEL_CAPACITY: usize = 256; + +/// Attributes of a locally originated route. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocalRouteAttrs { + pub origin: OriginPathAttribute, + /// Number of *additional* copies of the local ASN to prepend to the + /// AS_PATH. `0` yields the plain `[local_asn]` path. + pub prepend: u8, + pub med: Option, + /// RFC 1997 standard communities as `(high, low)` 16-bit halves. + pub communities: Vec<(u16, u16)>, + /// RFC 8092 large communities. + pub large_communities: Vec, +} + +impl Default for LocalRouteAttrs { + fn default() -> Self { + Self { + origin: OriginPathAttribute::IGP, + prepend: 0, + med: None, + communities: vec![], + large_communities: vec![], + } + } +} + +/// A route originated by this router. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LocalRoute { + pub prefix: IpPrefix, + pub attrs: LocalRouteAttrs, + pub announced_at: SystemTime, +} + +impl LocalRoute { + pub fn new(prefix: IpPrefix, attrs: LocalRouteAttrs) -> Self { + Self { + prefix, + attrs, + announced_at: SystemTime::now(), + } + } +} + +const STOPPED: RibStopped = RibStopped { rib: "local RIB" }; + +#[derive(Debug, Clone)] +pub enum LocalRibEvent { + /// A route was added or its attributes replaced. + Announced(LocalRoute), + Withdrawn { + prefix: IpPrefix, + }, +} + +enum Command { + Announce { + route: LocalRoute, + reply: oneshot::Sender<()>, + }, + Withdraw { + prefix: IpPrefix, + reply: oneshot::Sender>, + }, + Snapshot { + reply: oneshot::Sender>, + }, + Len { + reply: oneshot::Sender, + }, +} + +/// Handle to the local RIB actor. Cheap to clone; all clones share one table. +/// +/// Every operation fails with [`RibStopped`] if the actor is gone; see +/// there for what that means. +#[derive(Clone)] +pub struct LocalRib { + tx: mpsc::Sender, + events: broadcast::Sender, + actor: AbortHandle, +} + +impl LocalRib { + pub fn spawn() -> Self { + let (tx, rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); + let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let actor = tokio::spawn(run_actor(rx, events.clone())).abort_handle(); + Self { tx, events, actor } + } + + /// Adds a route, or replaces the existing route for the same prefix. + pub async fn announce(&self, route: LocalRoute) -> Result<(), RibStopped> { + self.ask(|reply| Command::Announce { route, reply }).await + } + + /// Removes a route. Returns the removed route, or `None` if the prefix + /// was not present. + pub async fn withdraw(&self, prefix: IpPrefix) -> Result, RibStopped> { + self.ask(|reply| Command::Withdraw { prefix, reply }).await + } + + /// Point-in-time copy of every route, in no particular order. + pub async fn snapshot(&self) -> Result, RibStopped> { + self.ask(|reply| Command::Snapshot { reply }).await + } + + /// How many routes the RIB holds, without copying them. + pub async fn len(&self) -> Result { + self.ask(|reply| Command::Len { reply }).await + } + + /// Whether the RIB holds no routes. + pub async fn is_empty(&self) -> Result { + Ok(self.len().await? == 0) + } + + /// Sends a command and waits for its answer. + async fn ask( + &self, + command: impl FnOnce(oneshot::Sender) -> Command, + ) -> Result { + let (reply, answer) = oneshot::channel(); + self.tx.send(command(reply)).await.map_err(|_| STOPPED)?; + answer.await.map_err(|_| STOPPED) + } + + /// Resolves when the actor has stopped. It never does in a healthy + /// process; whoever owns the RIB waits on this to notice a failure even + /// when nothing happens to be using the table. + pub async fn stopped(&self) { + self.tx.closed().await + } + + /// Stops the actor, as a panic in it would. For tests of how the rest + /// of the server copes. + #[doc(hidden)] + pub fn stop_for_testing(&self) { + self.actor.abort(); + } + + /// Subscribe to mutations. A subscriber that falls too far behind is + /// told so with [`broadcast::error::RecvError::Lagged`]. + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } +} + +async fn run_actor(mut rx: mpsc::Receiver, events: broadcast::Sender) { + let mut table: HashMap = HashMap::new(); + + while let Some(cmd) = rx.recv().await { + match cmd { + Command::Announce { route, reply } => { + table.insert(route.prefix, route.clone()); + let _ = events.send(LocalRibEvent::Announced(route)); + let _ = reply.send(()); + } + Command::Withdraw { prefix, reply } => { + let removed = table.remove(&prefix); + if removed.is_some() { + let _ = events.send(LocalRibEvent::Withdrawn { prefix }); + } + let _ = reply.send(removed); + } + Command::Snapshot { reply } => { + // A failed reply means the asker stopped waiting. + let _ = reply.send(table.values().cloned().collect()); + } + Command::Len { reply } => { + let _ = reply.send(table.len()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() + } + + fn route(p: &str) -> LocalRoute { + LocalRoute::new(prefix(p), LocalRouteAttrs::default()) + } + + #[tokio::test] + async fn announce_and_withdraw_emit_events() { + let rib = LocalRib::spawn(); + let mut events = rib.subscribe(); + + rib.announce(route("10.0.0.0/24")).await.unwrap(); + assert!( + matches!(events.recv().await.unwrap(), LocalRibEvent::Announced(r) if r.prefix == prefix("10.0.0.0/24")) + ); + assert_eq!(rib.len().await, Ok(1)); + + let removed = rib.withdraw(prefix("10.0.0.0/24")).await.unwrap(); + assert_eq!(removed.unwrap().prefix, prefix("10.0.0.0/24")); + assert!( + matches!(events.recv().await.unwrap(), LocalRibEvent::Withdrawn { prefix: p } if p == prefix("10.0.0.0/24")) + ); + assert_eq!(rib.is_empty().await, Ok(true)); + + // Withdrawing an absent prefix is a no-op, not an error. + assert_eq!(rib.withdraw(prefix("10.0.0.0/24")).await.unwrap(), None); + } + + #[tokio::test] + async fn announcing_a_prefix_again_replaces_its_attributes() { + let rib = LocalRib::spawn(); + rib.announce(route("10.0.0.0/24")).await.unwrap(); + let attrs = LocalRouteAttrs { + med: Some(50), + ..Default::default() + }; + rib.announce(LocalRoute::new(prefix("10.0.0.0/24"), attrs.clone())) + .await + .unwrap(); + + let table = rib.snapshot().await.unwrap(); + assert_eq!(table.len(), 1); + assert_eq!(table[0].attrs, attrs); + } + + #[tokio::test] + async fn a_stopped_actor_is_an_error_and_not_an_empty_table() { + let rib = LocalRib::spawn(); + rib.announce(route("10.0.0.0/24")).await.unwrap(); + + rib.stop_for_testing(); + rib.stopped().await; + + assert_eq!(rib.announce(route("10.0.1.0/24")).await, Err(STOPPED)); + assert_eq!(rib.withdraw(prefix("10.0.0.0/24")).await, Err(STOPPED)); + assert_eq!(rib.snapshot().await, Err(STOPPED)); + assert_eq!(rib.len().await, Err(STOPPED)); + } +} diff --git a/crates/rib/src/table/attrs.rs b/crates/rib/src/table/attrs.rs new file mode 100644 index 0000000..b11f89f --- /dev/null +++ b/crates/rib/src/table/attrs.rs @@ -0,0 +1,449 @@ +//! Attribute sets, stored once. +//! +//! A full table has millions of routes and far fewer distinct lists of path +//! attributes: a peer announces prefixes that share their attributes +//! together, about six to an UPDATE on the Internet today. A route in the +//! table is therefore a pointer to an [`AttrSet`], and each distinct set +//! exists once, in the [`AttrStore`]. +//! +//! A set is kept as its bytes on the wire, which is a fifth the size of the +//! parsed form. What choosing between routes needs from it is worked out +//! once, when the set is stored, and kept beside the bytes. + +use std::hash::BuildHasher; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use bgp_packet::message::{NlriNextHop, PathAttribute, attr_type, split_attribute}; +use bgp_primitives::parser::{EncodeError, ParserContext}; +use bytes::{BufMut, BytesMut}; +use foldhash::fast::RandomState; +use hashbrown::HashTable; + +const OPTIONAL: u8 = 0x80; + +/// What route selection reads from a set of attributes (RFC 4271 §9.1.2.2). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SelectionKey { + /// LOCAL_PREF, if the set has one. + pub local_pref: Option, + /// MULTI_EXIT_DISC, if the set has one. + pub med: Option, + /// The AS that the route was learned from: the first AS of the path, or + /// zero if the path is empty or begins with a set. MEDs are comparable + /// only between routes with the same one. + pub neighbor_as: u32, + /// The length of the AS path as selection counts it: an AS_SET is one + /// hop however many AS numbers it holds. + pub as_path_len: u16, + /// ORIGIN: 0 for IGP, 1 for EGP, 2 for INCOMPLETE. + pub origin: u8, +} + +/// Reads the selection key straight off attributes in the form an +/// [`AttrSet`] keeps, without building anything. `None` if the bytes are +/// not a well-formed list of attributes. +fn scan(mut wire: &[u8]) -> Option { + let mut key = SelectionKey { + local_pref: None, + med: None, + neighbor_as: 0, + as_path_len: 0, + // A route without ORIGIN does not get this far; if one did, it + // would be the least preferred. + origin: 2, + }; + let number = |value: &[u8]| Some(u32::from_be_bytes(<[u8; 4]>::try_from(value).ok()?)); + while !wire.is_empty() { + let (attribute, rest) = split_attribute(wire).ok()?; + wire = rest; + let value = attribute.value; + match attribute.type_code { + attr_type::ORIGIN => key.origin = *value.first()?, + attr_type::AS_PATH => (key.as_path_len, key.neighbor_as) = scan_as_path(value)?, + attr_type::MULTI_EXIT_DISC => key.med = Some(number(value)?), + attr_type::LOCAL_PREF => key.local_pref = Some(number(value)?), + _ => {} + } + } + Some(key) +} + +/// The length of an AS_PATH as selection counts it, and the neighbouring +/// AS, from the attribute's value with four-octet AS numbers. +fn scan_as_path(mut segments: &[u8]) -> Option<(u16, u32)> { + const AS_SEQUENCE: u8 = 2; + let mut hops = 0usize; + let mut neighbor_as = 0; + let mut first = true; + while let [kind, count, rest @ ..] = segments { + let (asns, rest) = rest.split_at_checked(usize::from(*count) * 4)?; + let ordered = *kind == AS_SEQUENCE; + hops += if ordered { usize::from(*count) } else { 1 }; + if first && ordered { + neighbor_as = asns + .first_chunk::<4>() + .map_or(0, |asn| u32::from_be_bytes(*asn)); + } + first = false; + segments = rest; + } + if !segments.is_empty() { + return None; + } + Some((u16::try_from(hops).unwrap_or(u16::MAX), neighbor_as)) +} + +/// One distinct list of path attributes, with the next hop that goes with +/// it. Made by an [`AttrStore`]; two routes have the same attributes +/// exactly when they point to the same `AttrSet`. +#[derive(Debug)] +pub struct AttrSet { + hash: u64, + key: SelectionKey, + /// The attributes as on the wire, with four-octet AS numbers. A next + /// hop that arrived in MP_REACH_NLRI is kept as an MP_REACH_NLRI that + /// holds nothing else, the form RFC 6396 §4.3.4 uses in table dumps. + wire: Box<[u8]>, +} + +impl AttrSet { + pub fn key(&self) -> &SelectionKey { + &self.key + } + + /// The attributes as they are on the wire. + pub fn wire(&self) -> &[u8] { + &self.wire + } + + /// The attributes, parsed, and the next hop if it came in + /// MP_REACH_NLRI. + /// + /// `None` if they do not parse. Sets made from parsed attributes or + /// from an UPDATE the decoder accepted always do; one made from bytes + /// of another source ([`AttrStore::intern_wire`]) was checked only as + /// far as selection reads it. + pub fn attributes(&self) -> Option<(Vec, Option)> { + parse(&self.wire) + } +} + +/// How the attributes of a set are written: with four-octet AS numbers. +fn four_octet() -> ParserContext { + ParserContext { + four_octet_asn: true, + ibgp: true, + add_path: Vec::new(), + } +} + +/// Writes `attributes` and `next_hop` in the form an [`AttrSet`] keeps. +fn encode( + attributes: &[PathAttribute], + next_hop: Option<&NlriNextHop>, + out: &mut BytesMut, +) -> Result<(), EncodeError> { + let ctx = four_octet(); + for attribute in attributes { + // These name prefixes, which are not a property of the route. + if matches!( + attribute, + PathAttribute::MpReachNlri(_) | PathAttribute::MpUnreachNlri(_) + ) { + continue; + } + attribute.to_wire(&ctx, out)?; + } + if let Some(next_hop) = next_hop { + encode_next_hop(next_hop, out); + } + Ok(()) +} + +/// Writes a next hop as an MP_REACH_NLRI that holds nothing else. +fn encode_next_hop(next_hop: &NlriNextHop, out: &mut BytesMut) { + out.put_u8(OPTIONAL); + out.put_u8(attr_type::MP_REACH_NLRI); + out.put_u8(1 + next_hop.wire_len()); + out.put_u8(next_hop.wire_len()); + next_hop.to_wire(out); +} + +/// Parses path attributes in the form [`AttrSet`] keeps and table dumps +/// use (RFC 6396 §4.3.4): four-octet AS numbers, and an MP_REACH_NLRI that +/// holds only a next hop. `None` if they are malformed. +fn parse(mut wire: &[u8]) -> Option<(Vec, Option)> { + let ctx = four_octet(); + let mut attributes = Vec::new(); + let mut next_hop = None; + while !wire.is_empty() { + let (raw, rest) = split_attribute(wire).ok()?; + if raw.type_code == attr_type::MP_REACH_NLRI { + let (length, address) = raw.value.split_first()?; + if usize::from(*length) != address.len() { + return None; + } + next_hop = Some(NlriNextHop::from_bytes(address)?); + } else { + let (_, attribute) = PathAttribute::from_wire(&ctx, raw.wire).ok()?; + attributes.push(attribute); + } + wire = rest; + } + Some((attributes, next_hop)) +} + +/// How many locks the store is split over. Sessions intern concurrently, +/// once per UPDATE; this many locks keep them out of each other's way. +const STRIPES: usize = 64; + +/// The one place attribute sets are kept. Safe to share between threads. +pub struct AttrStore { + stripes: Box<[Mutex>>]>, + hasher: RandomState, +} + +impl Default for AttrStore { + fn default() -> Self { + Self::new() + } +} + +impl AttrStore { + pub fn new() -> Self { + Self { + stripes: (0..STRIPES).map(|_| Mutex::default()).collect(), + hasher: RandomState::default(), + } + } + + /// The stored set equal to `attributes` and `next_hop`, added if it was + /// not there. `next_hop` is the one from MP_REACH_NLRI; routes whose + /// next hop is the NEXT_HOP attribute pass `None`. + /// + /// Fails only if the attributes cannot be encoded, which attributes that + /// were decoded from a message always can. + pub fn intern( + &self, + attributes: &[PathAttribute], + next_hop: Option<&NlriNextHop>, + ) -> Result, EncodeError> { + let mut wire = BytesMut::with_capacity(256); + encode(attributes, next_hop, &mut wire)?; + Ok(self + .intern_wire(&wire) + .expect("attributes written here are well formed")) + } + + /// As [`AttrStore::intern`], for attributes that are already in the + /// form a set keeps: on the wire, with four-octet AS numbers, no + /// MP_UNREACH_NLRI, and an MP_REACH_NLRI (if any) that holds only the + /// next hop. That is how a table dump has them (RFC 6396 §4.3.4). + /// + /// Nothing is parsed: finding a set that is already stored costs a hash + /// of the bytes and a comparison. `None` if the bytes are not a + /// well-formed list of attributes. + pub fn intern_wire(&self, wire: &[u8]) -> Option> { + let hash = self.hasher.hash_one(wire); + let mut stripe = self.stripe(hash); + if let Some(existing) = stripe.find(hash, |set| *set.wire == *wire) { + return Some(Arc::clone(existing)); + } + let set = Arc::new(AttrSet { + hash, + key: scan(wire)?, + wire: wire.into(), + }); + stripe.insert_unique(hash, Arc::clone(&set), |set| set.hash); + Some(set) + } + + /// The set for attributes as a session received them: `wire` is what + /// the decoder kept of the UPDATE's attributes + /// (`DecodedUpdate::attributes_wire`), and `next_hop` the next hop of + /// the routes if they came in MP_REACH_NLRI. + pub fn intern_received( + &self, + wire: &[u8], + next_hop: Option<&NlriNextHop>, + ) -> Option> { + let Some(next_hop) = next_hop else { + return self.intern_wire(wire); + }; + let mut with_next_hop = BytesMut::with_capacity(wire.len() + 36); + with_next_hop.put_slice(wire); + encode_next_hop(next_hop, &mut with_next_hop); + self.intern_wire(&with_next_hop) + } + + /// How many distinct sets are stored. + pub fn len(&self) -> usize { + (0..STRIPES).map(|i| self.stripe_at(i).len()).sum() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Drops the sets that no route refers to any more, and returns how + /// many that was. Sets are not dropped as their last route goes, since + /// that would mean a lock here for every route removed; this is called + /// now and then instead. + pub fn sweep(&self) -> usize { + let mut dropped = 0; + for i in 0..STRIPES { + let mut stripe = self.stripe_at(i); + let before = stripe.len(); + // The store's own reference is the only one left. Nobody can + // be taking another: that is done under this lock. + stripe.retain(|set| Arc::strong_count(set) > 1); + dropped += before - stripe.len(); + } + dropped + } + + fn stripe(&self, hash: u64) -> MutexGuard<'_, HashTable>> { + self.stripe_at(super::spread(hash, STRIPES)) + } + + fn stripe_at(&self, index: usize) -> MutexGuard<'_, HashTable>> { + // A panic while holding the lock cannot leave the table half + // changed in a way that matters: see `IgnorePoison` in the server. + self.stripes[index] + .lock() + .unwrap_or_else(PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use std::net::Ipv4Addr; + + use bgp_packet::message::{ + AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LocalPrefPathAttribute, + MultiExitDiscPathAttribute, NextHopPathAttribute, OriginPathAttribute, + }; + + use super::*; + + fn attributes(path: Vec) -> Vec { + vec![ + PathAttribute::Origin(OriginPathAttribute::EGP), + AsPathAttribute::from_asns(path), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)), + PathAttribute::Communities(CommunitiesPathAttribute(vec![(65000, 1)])), + ] + } + + #[test] + fn equal_attributes_are_one_set() { + let store = AttrStore::new(); + let a = store.intern(&attributes(vec![65001, 65002]), None).unwrap(); + let b = store.intern(&attributes(vec![65001, 65002]), None).unwrap(); + let c = store.intern(&attributes(vec![65001, 65003]), None).unwrap(); + assert!(Arc::ptr_eq(&a, &b)); + assert!(!Arc::ptr_eq(&a, &c)); + assert_eq!(store.len(), 2); + + // The next hop is part of what a set is. + let next_hop = NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()); + let d = store + .intern(&attributes(vec![65001, 65002]), Some(&next_hop)) + .unwrap(); + assert!(!Arc::ptr_eq(&a, &d)); + assert_eq!(store.len(), 3); + } + + #[test] + fn a_set_gives_back_what_went_in() { + let store = AttrStore::new(); + let next_hop = NlriNextHop::Ipv6WithLl { + global: "2001:db8::1".parse().unwrap(), + link_local: "fe80::1".parse().unwrap(), + }; + let original = attributes(vec![65001, 4_200_000_001]); + let set = store.intern(&original, Some(&next_hop)).unwrap(); + assert_eq!(set.attributes(), Some((original, Some(next_hop)))); + } + + #[test] + fn the_selection_key_is_read_off_the_attributes() { + let store = AttrStore::new(); + let mut attrs = vec![ + PathAttribute::Origin(OriginPathAttribute::INCOMPLETE), + PathAttribute::AsPath(AsPathAttribute { + segments: vec![ + AsPathSegment { + ordered: true, + path: vec![65001, 65002], + }, + // A set counts as one hop. + AsPathSegment { + ordered: false, + path: vec![65003, 65004, 65005], + }, + ], + }), + PathAttribute::LocalPref(LocalPrefPathAttribute(200)), + ]; + let key = *store.intern(&attrs, None).unwrap().key(); + assert_eq!( + key, + SelectionKey { + local_pref: Some(200), + med: None, + neighbor_as: 65001, + as_path_len: 3, + origin: 2, + } + ); + + attrs.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(7))); + assert_eq!(store.intern(&attrs, None).unwrap().key().med, Some(7)); + } + + #[test] + fn attributes_on_the_wire_are_interned_without_parsing_them() { + let store = AttrStore::new(); + let next_hop = NlriNextHop::Ipv6("2001:db8::1".parse().unwrap()); + let mut attrs = attributes(vec![65001, 65002, 65003]); + attrs.push(PathAttribute::LocalPref(LocalPrefPathAttribute(300))); + let parsed = store.intern(&attrs, Some(&next_hop)).unwrap(); + + // The same set by its bytes is the same set, key and all. + let by_wire = store.intern_wire(parsed.wire()).unwrap(); + assert!(Arc::ptr_eq(&parsed, &by_wire)); + + // And one that arrives as bytes first gets the same key as it would + // have had from the parsed form. + let other = AttrStore::new(); + let fresh = other.intern_wire(parsed.wire()).unwrap(); + assert_eq!(fresh.key(), parsed.key()); + assert_eq!(fresh.key().as_path_len, 3); + assert_eq!(fresh.key().neighbor_as, 65001); + assert_eq!(fresh.attributes(), Some((attrs, Some(next_hop)))); + + // Bytes that are not attributes are refused. + assert!(other.intern_wire(&[0x40, 0x01, 0x05, 0x00]).is_none()); + assert!( + other + .intern_wire(&[0x40, 0x02, 0x03, 0x02, 0x01, 0x00]) + .is_none() + ); + } + + #[test] + fn sweeping_drops_only_sets_nobody_holds() { + let store = AttrStore::new(); + let kept = store.intern(&attributes(vec![65001]), None).unwrap(); + drop(store.intern(&attributes(vec![65002]), None).unwrap()); + assert_eq!(store.len(), 2); + + assert_eq!(store.sweep(), 1); + assert_eq!(store.len(), 1); + // The one that is left is still the one that is found. + let again = store.intern(&attributes(vec![65001]), None).unwrap(); + assert!(Arc::ptr_eq(&kept, &again)); + } +} diff --git a/crates/rib/src/table/journal.rs b/crates/rib/src/table/journal.rs new file mode 100644 index 0000000..28dd8e3 --- /dev/null +++ b/crates/rib/src/table/journal.rs @@ -0,0 +1,755 @@ +//! The journal: how the rest of the router follows the best routes. +//! +//! Whatever acts on the table's choices (the peers that are sent the best +//! routes, the forwarding table, the status page) has to learn when a best +//! route changes, at its own pace, without the table waiting for it. Each +//! shard keeps a journal for that, and each follower reads it through a +//! [`Reader`]. +//! +//! # What the journal holds +//! +//! It records *that* a prefix's best route changed, not what it changed to. +//! A reader that comes to an entry looks the prefix up and is given the best +//! route as it is at that moment. A follower does not want the history; it +//! wants to end up with what the table has now. +//! +//! This is what keeps the journal small. If a prefix changes again before a +//! reader has got to its entry, the older entry no longer says anything: the +//! newer one will deliver the current route. Each row remembers the +//! sequence number of its latest change, an entry with any other number is +//! dead, and dead entries are thrown out when the journal is tidied. So a +//! prefix that flaps a thousand times costs a reader one event, and the +//! journal never has more live entries than the table has prefixes. +//! +//! # Readers that fall behind +//! +//! Entries are dropped once every reader has passed them. A reader that +//! stops reading would hold them all back, so there is a limit: when the +//! journal is too long even after tidying, its oldest half is dropped +//! regardless. A reader that was behind that point is told so the next time +//! it reads ([`Event::Reset`]) and is given the whole table again. Reading +//! too slowly costs the slow reader a resynchronisation; it costs the +//! table, and the other readers, nothing. +//! +//! A new reader starts the same way: `Reset`, every best route, `Synced`, +//! and changes from then on. +//! +//! # All routes, or only the best +//! +//! There are two journals in each shard. One records changes of best route, +//! for followers that want one route for each prefix: the forwarding table, +//! and peers that are sent the best route. The other records every change +//! to any route, for followers that want them all: peers that are sent +//! several routes for a prefix (ADD-PATH, RFC 7911), or a choice made for +//! them alone. A [`Reader`] follows one or the other. The second journal +//! costs nothing while nobody follows it. +//! +//! # Order +//! +//! For any one prefix, a reader is given its states in the order they came +//! about, and always, in the end, the latest. Between prefixes there is no +//! order worth relying on. + +use std::collections::VecDeque; +use std::hash::BuildHasher; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bgp_primitives::ip_prefix::IpPrefix; +use foldhash::fast::RandomState; +use hashbrown::{HashMap, HashTable}; + +use super::{AttrSet, PeerIndex, RibTable, RouteRef, Row, Shard, read, write}; + +/// A cursor that holds nothing back: a reader slot that is free, or a +/// reader that has not read this shard yet. +const NO_CURSOR: u64 = u64::MAX; + +/// When a journal is tidied, and when it is too long. +#[derive(Debug, Clone, Copy)] +pub(super) struct Limits { + /// Below this many entries a journal is not worth tidying. + pub(super) tidy_at: usize, + /// Room allowed beyond the number of prefixes before the journal is + /// thought too long. + pub(super) slack: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { + tidy_at: 1024, + slack: 64, + } + } +} + +struct Entry { + seq: u64, + prefix: IpPrefix, +} + +/// One journal of one shard. +struct Journal { + kind: Kind, + /// The sequence number of the latest change. Counts every change this + /// journal is for, whether or not anyone was reading. + seq: u64, + /// Entries up to and including this number were dropped without regard + /// to the readers. A reader whose cursor is below it missed something. + floor: u64, + /// In order of sequence number. Not every number is here: dead entries + /// are removed, and nothing is recorded while there are no readers. + entries: VecDeque, + /// Prefixes that have no route any more and whose disappearance is + /// still to be read, with the sequence number of the entry that says + /// so. A row keeps this number itself; a prefix without a row cannot. + gone: HashMap, + /// Where each reader has read to, by reader slot. Written by the + /// readers themselves while they hold the shard for reading. + cursors: Vec, + /// How many reader slots are in use. + readers: usize, + limits: Limits, +} + +impl Journal { + fn new(kind: Kind, limits: Limits) -> Self { + Self { + kind, + limits, + seq: 0, + floor: 0, + entries: VecDeque::new(), + gone: HashMap::default(), + cursors: Vec::new(), + readers: 0, + } + } + + /// Notes a change to `prefix`, which has a row. Returns the sequence + /// number for the row to remember. + fn changed(&mut self, prefix: IpPrefix) -> u64 { + self.seq += 1; + if self.readers > 0 { + self.entries.push_back(Entry { + seq: self.seq, + prefix, + }); + } + self.seq + } + + /// As [`Journal::changed`], for a prefix whose row was just made: + /// if it had gone and is back, the note that it had gone is void. + fn appeared(&mut self, prefix: IpPrefix) -> u64 { + if !self.gone.is_empty() { + self.gone.remove(&prefix); + } + self.changed(prefix) + } + + /// Notes that `prefix` lost its last route and has no row any more. + fn disappeared(&mut self, prefix: IpPrefix) { + self.seq += 1; + if self.readers > 0 { + self.entries.push_back(Entry { + seq: self.seq, + prefix, + }); + self.gone.insert(prefix, self.seq); + } + } + + /// Forgets that `entry`'s prefix had gone, if `entry` is what said so. + fn drop_entry(gone: &mut HashMap, entry: &Entry) { + if gone.get(&entry.prefix) == Some(&entry.seq) { + gone.remove(&entry.prefix); + } + } + + /// What `entry` says, if it still says something: it is the latest + /// change of its prefix. The row, or `None` for a prefix that has gone. + fn live<'a>( + &self, + entry: &Entry, + rows: &'a HashTable, + hasher: &RandomState, + ) -> Option> { + let hash = hasher.hash_one(entry.prefix); + match rows.find(hash, |row| row.prefix == entry.prefix) { + Some(row) => (row.seqs.of(self.kind) == entry.seq).then_some(Some(row)), + None => (self.gone.get(&entry.prefix) == Some(&entry.seq)).then_some(None), + } + } + + /// Keeps the journal from growing. Called after every change to the + /// shard; does nothing until there is something worth doing. + fn tidy(&mut self, rows: &HashTable, hasher: &RandomState) { + if self.entries.len() <= self.limits.tidy_at { + return; + } + // What every reader has passed can go. + let passed = self + .cursors + .iter() + .map(|cursor| cursor.load(Ordering::Acquire)) + .min() + .unwrap_or(NO_CURSOR); + while let Some(front) = self.entries.front() { + if front.seq > passed { + break; + } + Self::drop_entry(&mut self.gone, front); + self.entries.pop_front(); + } + + // Entries for prefixes that have a row are at most as many as the + // rows once the dead ones are out. Entries for prefixes that have + // gone are not counted in: they are what a reader that has stopped + // reading makes pile up. + let limit = 2 * (rows.len() + self.limits.slack); + if self.entries.len() <= limit { + return; + } + // Dead entries: their prefix has changed again since. This looks + // every entry up, but only once the journal has doubled, so the + // cost per change stays small. + let mut entries = std::mem::take(&mut self.entries); + entries.retain(|entry| self.live(entry, rows, hasher).is_some()); + self.entries = entries; + + if self.entries.len() <= limit { + return; + } + // Still too long: a reader is not reading while prefixes come and + // go, or more prefixes went at once than are left. The older half + // goes, and a reader that had not read it loses its place. Half, + // so that readers that are only a little behind keep theirs, and + // so that getting a long journal down to size takes few passes. + for entry in self.entries.drain(..self.entries.len() / 2) { + Self::drop_entry(&mut self.gone, &entry); + self.floor = entry.seq; + } + } +} + +/// Which of a shard's two journals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Kind { + /// Changes of best route. + Best, + /// Changes of any route. + All, +} + +/// The sequence numbers a row remembers, one for each journal: of the +/// latest change of its best route, and of any of its routes. +#[derive(Debug, Clone, Copy)] +pub(super) struct Seqs { + best: u64, + all: u64, +} + +impl Seqs { + fn of(self, kind: Kind) -> u64 { + match kind { + Kind::Best => self.best, + Kind::All => self.all, + } + } +} + +/// The two journals of a shard, written together: every change to a row is +/// a change of one of its routes, and some are a change of its best route. +pub(super) struct Journals { + best: Journal, + all: Journal, +} + +impl Journals { + pub(super) fn new(limits: Limits) -> Self { + Self { + best: Journal::new(Kind::Best, limits), + all: Journal::new(Kind::All, limits), + } + } + + fn of(&self, kind: Kind) -> &Journal { + match kind { + Kind::Best => &self.best, + Kind::All => &self.all, + } + } + + fn of_mut(&mut self, kind: Kind) -> &mut Journal { + match kind { + Kind::Best => &mut self.best, + Kind::All => &mut self.all, + } + } + + /// Notes that `prefix` has its first route, and so a row. Returns the + /// sequence numbers for the new row. + pub(super) fn appeared(&mut self, prefix: IpPrefix) -> Seqs { + Seqs { + best: self.best.appeared(prefix), + all: self.all.appeared(prefix), + } + } + + /// Notes that a route of `row` changed, and whether its best route did. + pub(super) fn changed(&mut self, row: &mut Row, best_changed: bool) { + if best_changed { + row.seqs.best = self.best.changed(row.prefix); + } + row.seqs.all = self.all.changed(row.prefix); + } + + /// Notes that `prefix` lost its last route and has no row any more. + pub(super) fn disappeared(&mut self, prefix: IpPrefix) { + self.best.disappeared(prefix); + self.all.disappeared(prefix); + } + + /// Keeps the journals from growing; see [`Journal::tidy`]. + pub(super) fn tidy(&mut self, rows: &HashTable, hasher: &RandomState) { + self.best.tidy(rows, hasher); + self.all.tidy(rows, hasher); + } + + /// Where the journals are now, to ask [`Journals::has_news_since`] + /// about later. + pub(super) fn mark(&self) -> Mark { + Mark(self.best.seq, self.all.seq) + } + + /// Whether anything was written since `mark` that some reader is there + /// to read. + pub(super) fn has_news_since(&self, mark: Mark) -> bool { + (self.best.readers > 0 && self.best.seq != mark.0) + || (self.all.readers > 0 && self.all.seq != mark.1) + } + + fn len(&self) -> usize { + self.best.entries.len() + self.all.entries.len() + } +} + +/// A point in a shard's journals; see [`Journals::mark`]. +#[derive(Clone, Copy)] +pub(super) struct Mark(u64, u64); + +/// The routes of one prefix, as a follower of all routes is given them. +#[derive(Clone, Copy)] +pub struct Routes<'a>(&'a Row); + +impl<'a> Routes<'a> { + pub fn iter(&self) -> impl Iterator> + use<'a> { + self.0.routes() + } + + /// How many there are: at least one. + pub fn len(&self) -> usize { + self.0.paths.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +impl std::fmt::Debug for Routes<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.iter()).finish() + } +} + +/// What a [`Reader`] is given. +#[derive(Debug, Clone, Copy)] +pub enum Event<'a> { + /// Forget what you know. The whole table follows, as [`Event::Best`] + /// or [`Event::Routes`] for every prefix, and then [`Event::Synced`]. + /// The first thing a new + /// reader is given, and what a reader that fell too far behind is + /// given in place of what it missed. + Reset, + /// This is now the best route for `prefix`. Given to a follower of the + /// best routes. + Best { + prefix: IpPrefix, + peer: PeerIndex, + /// The peer's identifier for the route: zero unless the peer + /// sends several routes for a prefix (ADD-PATH, RFC 7911). + path_id: u32, + attrs: &'a Arc, + }, + /// These are now all the routes for `prefix`: whatever was known of its + /// routes before is replaced. Given to a follower of all routes. + Routes { + prefix: IpPrefix, + routes: Routes<'a>, + }, + /// `prefix` has no route any more. + Withdrawn { prefix: IpPrefix }, + /// The table that began with [`Event::Reset`] is complete: a prefix + /// not heard of since the reset has no route. + Synced, +} + +/// What a call to [`Reader::poll`] got done. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Progress { + /// How many events were given out. + pub events: usize, + /// Whether the reader has now been given everything there was when the + /// call began. If not, call again. + pub caught_up: bool, +} + +enum Phase { + /// Giving out the whole table; `next` is the shard to do next, and + /// `announced` whether [`Event::Reset`] has been given. + Resync { next: usize, announced: bool }, + /// Following the journals; `next` is the shard to read next. + Follow { next: usize }, +} + +impl Phase { + /// The beginning of a resynchronisation. + const RESYNC: Self = Self::Resync { + next: 0, + announced: false, + }; +} + +/// Follows the routes of a table: the best of each prefix, or all of them. +/// Made by [`RibTable::reader`] and its siblings. +/// +/// A reader holds its place in every shard's journal. Dropping it gives +/// the place up, so that the journals need not keep entries for it. +pub struct Reader { + table: Arc, + /// Which journal it follows. + kind: Kind, + slot: usize, + /// Where this reader has read to in each shard; the copy in the shard + /// is for the shard's benefit. + cursors: Vec, + phase: Phase, + /// The table's version when the reader was last found caught up. + seen: u64, + caught_up: bool, +} + +impl RibTable { + /// A new follower of this table's best routes. It is given the whole + /// table first and every change after. + pub fn reader(self: &Arc) -> Reader { + self.register(Kind::Best, Phase::RESYNC) + } + + /// A new follower that is given only the changes from now on, for + /// those who want the news and not the table. If it falls too far + /// behind it is reset like any other, and is then given the table. + pub fn reader_from_now(self: &Arc) -> Reader { + let mut reader = self.register(Kind::Best, Phase::Follow { next: 0 }); + for (index, shard) in self.shards.iter().enumerate() { + let shard = read(shard); + reader.set_cursor(index, &shard, shard.journals.best.seq); + } + reader + } + + /// A new follower of every route in the table, not only the best: it + /// is told whenever any route of a prefix changes, with all the + /// prefix's routes ([`Event::Routes`]). The whole table first, like + /// [`RibTable::reader`]. + pub fn reader_of_all_routes(self: &Arc) -> Reader { + self.register(Kind::All, Phase::RESYNC) + } + + fn register(self: &Arc, kind: Kind, phase: Phase) -> Reader { + let mut slots = self + .reader_slots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let slot = match slots.iter().position(|used| !used) { + Some(free) => free, + None => { + slots.push(false); + slots.len() - 1 + } + }; + slots[slot] = true; + for shard in &self.shards { + let mut shard = write(shard); + let journal = shard.journals.of_mut(kind); + while journal.cursors.len() <= slot { + journal.cursors.push(AtomicU64::new(NO_CURSOR)); + } + journal.cursors[slot].store(NO_CURSOR, Ordering::Release); + journal.readers += 1; + } + self.readers.fetch_add(1, Ordering::AcqRel); + Reader { + table: Arc::clone(self), + kind, + slot, + cursors: vec![NO_CURSOR; self.shards.len()], + phase, + seen: 0, + caught_up: false, + } + } + + /// How many entries the journals hold, over all shards. For tests and + /// for watching: it stays bounded whatever the readers do. + pub fn journal_len(&self) -> usize { + self.shards + .iter() + .map(|shard| read(shard).journals.len()) + .sum() + } + + /// Tells readers that are waiting that there is something to read. + pub(super) fn wake_readers(&self) { + if self.readers.load(Ordering::Acquire) > 0 { + self.version.fetch_add(1, Ordering::AcqRel); + self.wake.notify_waiters(); + } + } +} + +impl Drop for Reader { + fn drop(&mut self) { + for shard in &self.table.shards { + let mut shard = write(shard); + let journal = shard.journals.of_mut(self.kind); + journal.cursors[self.slot].store(NO_CURSOR, Ordering::Release); + journal.readers -= 1; + if journal.readers == 0 { + // Nobody left to read it. + journal.entries.clear(); + journal.gone.clear(); + } + } + self.table.readers.fetch_sub(1, Ordering::AcqRel); + let mut slots = self + .table + .reader_slots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + slots[self.slot] = false; + } +} + +enum Drained { + /// Entries were dropped that this reader had not read. + Lagged, + /// `events` were given out; `more` if the budget ran out first. + Read { events: usize, more: bool }, +} + +impl Reader { + /// Gives `visit` what has changed since the last call, in up to about + /// `budget` events (a shard's part of a resynchronisation is not cut + /// short, so it can be more). + /// + /// `visit` is called with a shard of the table held for reading. It + /// should be quick, and it must not call into the table, even to read: + /// taking the shard's lock again can wait for ever on a writer that is + /// waiting for this hold to end. + pub fn poll(&mut self, budget: usize, mut visit: impl FnMut(Event<'_>)) -> Progress { + // A call that may give nothing out would never get anywhere. + let budget = budget.max(1); + let shards = self.table.shards.len(); + let mut events = 0; + loop { + match self.phase { + Phase::Resync { next, announced } => { + if !announced { + self.forget_place(); + visit(Event::Reset); + events += 1; + } + if next == shards { + visit(Event::Synced); + events += 1; + self.phase = Phase::Follow { next: 0 }; + continue; + } + events += self.scan(next, &mut visit); + self.phase = Phase::Resync { + next: next + 1, + announced: true, + }; + if events >= budget { + return self.progress(events, false); + } + } + Phase::Follow { next } => { + // Read before the pass: a change made during it shows as + // a newer version, and `changed` then returns at once. + let version = if next == 0 { + self.table.version.load(Ordering::Acquire) + } else { + self.seen + }; + self.seen = version; + let mut shard = next; + let mut lagged = false; + while shard < shards { + if events >= budget { + self.phase = Phase::Follow { next: shard }; + return self.progress(events, false); + } + match self.drain(shard, budget - events, &mut visit) { + Drained::Lagged => { + lagged = true; + break; + } + Drained::Read { events: read, more } => { + events += read; + if more { + self.phase = Phase::Follow { next: shard }; + return self.progress(events, false); + } + } + } + shard += 1; + } + if lagged { + self.phase = Phase::RESYNC; + continue; + } + self.phase = Phase::Follow { next: 0 }; + if next != 0 { + // This pass took up where an earlier call stopped. + // The shards before that point have not been looked + // at in this call, so go round again from the first. + continue; + } + return self.progress(events, true); + } + } + } + } + + fn progress(&mut self, events: usize, caught_up: bool) -> Progress { + self.caught_up = caught_up; + Progress { events, caught_up } + } + + /// Waits until there may be something to read. Returns at once if the + /// last [`Reader::poll`] did not catch up. + pub async fn changed(&mut self) { + loop { + // Registered before the check, so that a change between the + // check and the wait is not missed. + let woken = self.table.wake.notified(); + tokio::pin!(woken); + woken.as_mut().enable(); + if !self.caught_up || self.table.version.load(Ordering::Acquire) != self.seen { + return; + } + woken.await; + } + } + + /// What this reader is told about a row. + fn event_for<'a>(&self, row: &'a Row) -> Event<'a> { + match self.kind { + Kind::Best => { + let best = row.route(row.best()); + Event::Best { + prefix: row.prefix, + peer: best.peer, + path_id: best.path_id, + attrs: best.attrs, + } + } + Kind::All => Event::Routes { + prefix: row.prefix, + routes: Routes(row), + }, + } + } + + /// Gives up this reader's place in every journal, before a + /// resynchronisation takes a new one. + fn forget_place(&mut self) { + for (index, shard) in self.table.shards.iter().enumerate() { + read(shard).journals.of(self.kind).cursors[self.slot] + .store(NO_CURSOR, Ordering::Release); + self.cursors[index] = NO_CURSOR; + } + } + + fn set_cursor(&mut self, index: usize, shard: &Shard, cursor: u64) { + self.cursors[index] = cursor; + shard.journals.of(self.kind).cursors[self.slot].store(cursor, Ordering::Release); + } + + /// Gives out every prefix in a shard and takes a place at the end of + /// its journal, in one hold of the shard: what changes afterwards is in + /// the journal after that place. + fn scan(&mut self, index: usize, visit: &mut impl FnMut(Event<'_>)) -> usize { + let table = Arc::clone(&self.table); + let shard = read(&table.shards[index]); + for row in &shard.rows { + visit(self.event_for(row)); + } + self.set_cursor(index, &shard, shard.journals.of(self.kind).seq); + shard.rows.len() + } + + /// Reads a shard's journal from this reader's place, for at most `max` + /// events. + fn drain(&mut self, index: usize, max: usize, visit: &mut impl FnMut(Event<'_>)) -> Drained { + let table = Arc::clone(&self.table); + let shard = read(&table.shards[index]); + let journal = shard.journals.of(self.kind); + let mut cursor = self.cursors[index]; + if cursor < journal.floor { + return Drained::Lagged; + } + if cursor == journal.seq { + return Drained::Read { + events: 0, + more: false, + }; + } + + let start = journal.entries.partition_point(|entry| entry.seq <= cursor); + let mut events = 0; + let mut more = false; + for entry in journal.entries.range(start..) { + if events == max { + more = true; + break; + } + cursor = entry.seq; + let event = match journal.live(entry, &shard.rows, &shard.hasher) { + Some(Some(row)) => self.event_for(row), + Some(None) => Event::Withdrawn { + prefix: entry.prefix, + }, + // The prefix has changed again; a later entry has it. + None => continue, + }; + visit(event); + events += 1; + } + if !more { + // Past the last entry there is nothing up to the latest change. + cursor = journal.seq; + } + self.set_cursor(index, &shard, cursor); + Drained::Read { events, more } + } +} + +#[cfg(test)] +#[path = "journal_tests.rs"] +mod tests; diff --git a/crates/rib/src/table/journal_tests.rs b/crates/rib/src/table/journal_tests.rs new file mode 100644 index 0000000..9c72bc6 --- /dev/null +++ b/crates/rib/src/table/journal_tests.rs @@ -0,0 +1,713 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use bgp_primitives::constants::IpVersion; +use bgp_primitives::ip_prefix::IpPrefix; +use proptest::prelude::*; + +use super::super::tests::{Fixture, Route, external, internal, prefix, route}; +use super::super::{AttrSet, PeerIndex, PeerInfo, RibTable}; +use super::{Event, Limits, Reader}; + +/// What a follower would keep: its own copy of the best routes, brought +/// up to date from the events it is given. +#[derive(Default)] +struct Mirror { + best: HashMap)>, + /// What was held before a reset and has not been heard of since. + stale: HashMap)>, + /// For a follower of all routes: every route of every prefix, as + /// (peer, path identifier, attributes, whether best). + routes: HashMap>, + resets: usize, + synced: usize, + best_events: usize, + routes_events: usize, + withdrawn_events: usize, +} + +/// A route as a mirror holds it. The attributes are held, so that the +/// address used to compare them stays theirs. +type Held = (PeerIndex, u32, Arc, bool); + +fn same_routes(a: &[Held], b: &[Held]) -> bool { + a.len() == b.len() + && a.iter() + .zip(b) + .all(|(a, b)| (a.0, a.1, a.3) == (b.0, b.1, b.3) && Arc::ptr_eq(&a.2, &b.2)) +} + +fn sorted(mut routes: Vec) -> Vec { + routes.sort_by_key(|(peer, path_id, _, _)| (*peer, *path_id)); + routes +} + +impl Mirror { + fn apply(&mut self, event: Event<'_>) { + match event { + Event::Reset => { + self.resets += 1; + self.stale = std::mem::take(&mut self.best); + self.routes.clear(); + } + Event::Routes { prefix, routes } => { + self.routes_events += 1; + assert!(!routes.is_empty(), "a prefix with no routes is withdrawn"); + self.routes.insert( + prefix, + sorted( + routes + .iter() + .map(|r| (r.peer, r.path_id, Arc::clone(r.attrs), r.best)) + .collect(), + ), + ); + } + Event::Best { + prefix, + peer, + attrs, + .. + } => { + self.best_events += 1; + self.stale.remove(&prefix); + self.best.insert(prefix, (peer, Arc::clone(attrs))); + } + Event::Withdrawn { prefix } => { + self.withdrawn_events += 1; + self.best.remove(&prefix); + self.routes.remove(&prefix); + } + Event::Synced => { + self.synced += 1; + self.stale.clear(); + } + } + } + + /// Reads until there is nothing more, in steps of `budget`. + fn catch_up(&mut self, reader: &mut Reader, budget: usize) { + while !reader.poll(budget, |event| self.apply(event)).caught_up {} + } + + #[track_caller] + fn assert_matches(&self, table: &RibTable) { + let mut expected = HashMap::new(); + table.for_each_best(|prefix, peer, attrs| { + expected.insert(*prefix, (peer, Arc::clone(attrs))); + }); + assert_eq!( + self.best.len(), + expected.len(), + "the mirror has {} prefixes, the table {}", + self.best.len(), + expected.len() + ); + for (prefix, (peer, attrs)) in &expected { + let Some((mirrored_peer, mirrored_attrs)) = self.best.get(prefix) else { + panic!("{prefix} is in the table and not in the mirror"); + }; + assert_eq!(mirrored_peer, peer, "{prefix}: from the wrong peer"); + assert!( + Arc::ptr_eq(mirrored_attrs, attrs), + "{prefix}: wrong attributes" + ); + } + } +} + +impl Mirror { + /// For the mirror of a follower of all routes: it holds every route in + /// the table, and knows which is best. + #[track_caller] + fn assert_has_all_routes_of(&self, table: &RibTable) { + let mut expected: HashMap> = HashMap::new(); + table.for_each_route(|r| { + expected.entry(r.prefix).or_default().push(( + r.peer, + r.path_id, + Arc::clone(r.attrs), + r.best, + )); + }); + assert_eq!(self.routes.len(), expected.len(), "prefixes"); + for (prefix, routes) in expected { + let mirrored = self + .routes + .get(&prefix) + .unwrap_or_else(|| panic!("{prefix} is in the table and not in the mirror")); + assert!( + same_routes(mirrored, &sorted(routes)), + "{prefix}: the mirror's routes are not the table's" + ); + } + } +} + +fn nth_prefix(n: u32) -> IpPrefix { + IpPrefix::new(IpVersion::Ipv4, (n << 8).to_be_bytes(), 24).unwrap() +} + +#[test] +fn a_new_reader_is_given_the_table_and_then_the_changes() { + let fixture = Fixture::new(&[external(1), external(2)]); + let (a, b) = (prefix("10.0.0.0/8"), prefix("2001:db8::/32")); + fixture.announce(0, &a, &Route::via(&[1, 2])); + fixture.announce(0, &b, &Route::via(&[1, 2])); + + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + let mut events = Vec::new(); + let progress = reader.poll(100, |event| { + events.push( + format!("{event:?}") + .split([' ', '{']) + .next() + .unwrap() + .to_string(), + ); + mirror.apply(event); + }); + assert!(progress.caught_up); + assert_eq!(events, ["Reset", "Best", "Best", "Synced"]); + mirror.assert_matches(&fixture.table); + + // Nothing has happened: nothing to read. + assert_eq!(reader.poll(100, |_| panic!("no event expected")).events, 0); + + // A change that does not change the best route is not an event. + fixture.announce(1, &a, &Route::via(&[3, 4, 5])); + assert_eq!(reader.poll(100, |_| panic!("no event expected")).events, 0); + + fixture.announce(1, &a, &Route::via(&[3])); + fixture.withdraw(0, &b); + mirror.catch_up(&mut reader, 100); + assert_eq!((mirror.best_events, mirror.withdrawn_events), (3, 1)); + assert_eq!((mirror.resets, mirror.synced), (1, 1)); + mirror.assert_matches(&fixture.table); +} + +#[test] +fn a_reader_from_now_is_given_the_changes_and_not_the_table() { + let fixture = Fixture::new(&[external(1)]); + let (old, new) = (prefix("10.0.0.0/8"), prefix("10.1.0.0/16")); + fixture.announce(0, &old, &Route::via(&[1])); + + let mut reader = fixture.table.reader_from_now(); + assert_eq!( + reader + .poll(100, |event| panic!("unexpected {event:?}")) + .events, + 0 + ); + + fixture.announce(0, &new, &Route::via(&[1])); + fixture.withdraw(0, &old); + let (mut best, mut withdrawn) = (Vec::new(), Vec::new()); + reader.poll(100, |event| match event { + Event::Best { prefix, .. } => best.push(prefix), + Event::Withdrawn { prefix } => withdrawn.push(prefix), + other => panic!("unexpected {other:?}"), + }); + assert_eq!((best, withdrawn), (vec![new], vec![old])); +} + +/// A change to a route that is not the best is nothing to a follower of +/// the best routes, and is what a follower of all routes is there for. +#[test] +fn a_follower_of_all_routes_is_told_of_every_change() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + fixture.announce(0, &p, &Route::via(&[1])); + + let mut best = fixture.table.reader(); + let mut all = fixture.table.reader_of_all_routes(); + let (mut best_mirror, mut all_mirror) = (Mirror::default(), Mirror::default()); + best_mirror.catch_up(&mut best, 100); + all_mirror.catch_up(&mut all, 100); + assert_eq!((best_mirror.best_events, all_mirror.routes_events), (1, 1)); + + // A worse route from another peer, then two more from that peer under + // other path identifiers, one of them changed, one withdrawn. + fixture.announce(1, &p, &Route::via(&[2, 3, 4])); + fixture.announce_path(1, &p, 7, &Route::via(&[2, 3, 5])); + fixture.announce_path(1, &p, 8, &Route::via(&[2, 3, 6])); + fixture.announce_path(1, &p, 7, &Route::via(&[2, 9, 5])); + fixture.withdraw_path(1, &p, 8); + + assert_eq!( + best.poll(100, |event| panic!("unexpected {event:?}")) + .events, + 0 + ); + all_mirror.catch_up(&mut all, 100); + // All of it between two reads is one event, with the routes as they + // are now. + assert_eq!(all_mirror.routes_events, 2); + assert_eq!(all_mirror.routes[&p].len(), 3); + all_mirror.assert_has_all_routes_of(&fixture.table); + + // The last route going is a withdrawal for both. + fixture.table.remove_peer(fixture.peers[1]); + fixture.withdraw(0, &p); + best_mirror.catch_up(&mut best, 100); + all_mirror.catch_up(&mut all, 100); + assert_eq!( + (best_mirror.withdrawn_events, all_mirror.withdrawn_events), + (1, 1) + ); + all_mirror.assert_has_all_routes_of(&fixture.table); + + // Nobody following all routes: that journal keeps nothing. A change + // that is not of the best route is then recorded nowhere. + drop(all); + fixture.announce(0, &p, &Route::via(&[1])); + fixture.announce(1, &p, &Route::via(&[2, 3])); + let before = fixture.table.journal_len(); + fixture.announce(1, &p, &Route::via(&[2, 4])); + fixture.announce_path(1, &p, 7, &Route::via(&[2, 5])); + assert_eq!(fixture.table.journal_len(), before); +} + +/// A follower wants what the table has now, not how it got there. +#[test] +fn many_changes_to_a_prefix_between_reads_are_one_event() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + mirror.catch_up(&mut reader, 100); + + for round in 0..50 { + fixture.announce(0, &p, &Route::via(&[1, 2, 3])); + fixture.announce(1, &p, &Route::via(&[4, 5])); + fixture.withdraw(1, &p); + if round % 2 == 0 { + fixture.withdraw(0, &p); + } + } + // It ended with peer 0's route in place. + mirror.catch_up(&mut reader, 100); + assert_eq!((mirror.best_events, mirror.withdrawn_events), (1, 0)); + mirror.assert_matches(&fixture.table); + + // And a prefix that came and went again is, at most, a withdrawal. + let q = prefix("10.1.0.0/16"); + for _ in 0..50 { + fixture.announce(0, &q, &Route::via(&[1])); + fixture.withdraw(0, &q); + } + mirror.catch_up(&mut reader, 100); + assert_eq!((mirror.best_events, mirror.withdrawn_events), (1, 1)); + mirror.assert_matches(&fixture.table); +} + +#[test] +fn reading_in_small_steps_gives_the_same_as_reading_at_once() { + let fixture = Fixture::new(&[external(1)]); + for n in 0..500 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1, n % 7 + 1])); + } + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + mirror.catch_up(&mut reader, 7); + mirror.assert_matches(&fixture.table); + + for n in 0..500 { + if n % 3 == 0 { + fixture.withdraw(0, &nth_prefix(n)); + } else { + fixture.announce(0, &nth_prefix(n), &Route::via(&[2, n % 5 + 1])); + } + } + let mut steps = 0; + while !reader.poll(7, |event| mirror.apply(event)).caught_up { + steps += 1; + } + assert!(steps > 50, "the budget was kept to: {steps} steps"); + mirror.assert_matches(&fixture.table); + assert_eq!(mirror.resets, 1); +} + +#[test] +fn with_no_reader_nothing_is_kept() { + let fixture = Fixture::new(&[external(1)]); + for n in 0..5000 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1])); + fixture.withdraw(0, &nth_prefix(n)); + } + assert_eq!(fixture.table.journal_len(), 0); + + // And what a reader was holding back goes when the reader does. + let reader = fixture.table.reader(); + for n in 0..100 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1])); + } + assert_eq!(fixture.table.journal_len(), 100); + drop(reader); + assert_eq!(fixture.table.journal_len(), 0); +} + +/// A reader that stops reading must not make the table keep a growing +/// journal for it. It is the one that pays: it is given the table again. +#[test] +fn a_reader_that_stops_reading_is_reset_and_holds_nothing_up() { + // One shard, so that the journal's length is that shard's. + let fixture = Fixture::on(RibTable::new(1), &[external(1)]); + fixture.announce(0, &prefix("192.0.2.0/24"), &Route::via(&[1])); + + let mut stuck = fixture.table.reader(); + let mut stuck_mirror = Mirror::default(); + stuck_mirror.catch_up(&mut stuck, 100); + let mut diligent = fixture.table.reader(); + let mut diligent_mirror = Mirror::default(); + + // Prefixes come and go, each a different one, so no entry is made + // dead by a later one for the same prefix. + let limits = Limits::default(); + let mut longest = 0; + for n in 0..20_000 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1])); + fixture.withdraw(0, &nth_prefix(n)); + longest = longest.max(fixture.table.journal_len()); + if n % 100 == 0 { + diligent_mirror.catch_up(&mut diligent, 1000); + } + } + assert!( + longest <= limits.tidy_at.max(2 * (1 + limits.slack)) + 2, + "the journal reached {longest} entries" + ); + + // The one that kept up never noticed. + diligent_mirror.catch_up(&mut diligent, 1000); + assert_eq!( + diligent_mirror.resets, 1, + "only the reset every reader starts with" + ); + diligent_mirror.assert_matches(&fixture.table); + + // The one that did not is told, and ends up right all the same. + stuck_mirror.catch_up(&mut stuck, 1000); + assert_eq!(stuck_mirror.resets, 2); + assert_eq!(stuck_mirror.synced, 2); + stuck_mirror.assert_matches(&fixture.table); +} + +/// When a peer that was the only source of most prefixes goes, more +/// prefixes disappear at once than are left. The journal still comes down +/// to its bound, and a reader ends up with what the table has. +#[test] +fn many_prefixes_going_at_once_leaves_the_journal_bounded() { + let fixture = Fixture::on(RibTable::new(1), &[external(1), external(2)]); + for n in 0..5000 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1])); + } + fixture.announce(1, &nth_prefix(0), &Route::via(&[2, 3])); + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + mirror.catch_up(&mut reader, 1000); + + fixture.table.remove_peer(fixture.peers[0]); + // Some later change, as there always is, lets the journal be tidied. + for _ in 0..8 { + fixture.announce(1, &nth_prefix(1), &Route::via(&[2])); + fixture.withdraw(1, &nth_prefix(1)); + } + let limits = Limits::default(); + assert!( + fixture.table.journal_len() <= limits.tidy_at.max(2 * (1 + limits.slack)) + 2, + "the journal holds {} entries", + fixture.table.journal_len() + ); + mirror.catch_up(&mut reader, 1000); + mirror.assert_matches(&fixture.table); + assert_eq!(mirror.best.len(), 1); +} + +/// Readers are woken to read. An announcement that changes nothing, or +/// nothing a reader follows, writes nothing for them and must not wake +/// them: each would look through every shard to find that out. +#[test] +fn readers_are_not_woken_when_nothing_was_written_for_them() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + let version = || fixture.table.version.load(Ordering::Acquire); + + // Nobody reading: nobody to wake. + fixture.announce(0, &p, &Route::via(&[1])); + assert_eq!(version(), 0); + + let _best = fixture.table.reader(); + let before = version(); + // The same route again. + fixture.announce(0, &p, &Route::via(&[1])); + assert_eq!(version(), before); + // A worse route from another peer: the best route stands. + fixture.announce(1, &p, &Route::via(&[2, 3, 4])); + assert_eq!(version(), before); + // A better one. + fixture.announce(1, &p, &Route::via(&[])); + assert_eq!(version(), before + 1); + + // A follower of all routes is told of any change to a route. + let _all = fixture.table.reader_of_all_routes(); + let before = version(); + fixture.announce(0, &p, &Route::via(&[1, 1, 1])); + assert_eq!(version(), before + 1); + fixture.announce(0, &p, &Route::via(&[1, 1, 1])); + assert_eq!(version(), before + 1); +} + +/// A budget of nothing would let a reader ask for ever without getting +/// anywhere; it is taken as the smallest that does. +#[test] +fn a_budget_of_nothing_still_makes_progress() { + let fixture = Fixture::new(&[external(1)]); + for n in 0..20 { + fixture.announce(0, &nth_prefix(n), &Route::via(&[1])); + } + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + mirror.catch_up(&mut reader, 0); + for n in 0..20 { + fixture.withdraw(0, &nth_prefix(n)); + } + mirror.catch_up(&mut reader, 0); + mirror.assert_matches(&fixture.table); + assert!(mirror.best.is_empty()); +} + +#[tokio::test] +async fn a_waiting_reader_is_woken_by_a_change_of_best_route() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + let mut reader = fixture.table.reader(); + let mut mirror = Mirror::default(); + + // Not caught up yet: no waiting. + reader.changed().await; + mirror.catch_up(&mut reader, 100); + + let waiting = tokio::time::timeout(std::time::Duration::from_millis(50), reader.changed()); + assert!(waiting.await.is_err(), "nothing changed, so it waits"); + + let table = Arc::clone(&fixture.table); + let attrs = table + .attrs() + .intern(&Route::via(&[1]).attributes(), None) + .unwrap(); + let peer = fixture.peers[0]; + let writer = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + table.announce(peer, &attrs, &[p]); + }); + tokio::time::timeout(std::time::Duration::from_secs(5), reader.changed()) + .await + .expect("woken by the announcement"); + writer.await.unwrap(); + mirror.catch_up(&mut reader, 100); + mirror.assert_matches(&fixture.table); + + // A change made before the wait began is not missed either. + fixture.withdraw(0, &p); + tokio::time::timeout(std::time::Duration::from_secs(5), reader.changed()) + .await + .expect("the change was already there"); +} + +/// Writers on several threads, a reader on another, reading while they +/// write. When the writers are done and the reader has caught up, it has +/// what the table has. +#[test] +fn a_reader_keeps_up_with_writers_on_other_threads() { + const WRITERS: usize = 4; + const PREFIXES: u32 = 300; + let infos: Vec = (0..WRITERS).map(|i| external(i as u8 + 1)).collect(); + // Small journals, so that tidying and falling behind happen too. + let fixture = Fixture::on( + RibTable::with_journal_limits( + 8, + Limits { + tidy_at: 16, + slack: 4, + }, + ), + &infos, + ); + let table = &fixture.table; + let done = AtomicBool::new(false); + let mut mirror = Mirror::default(); + + std::thread::scope(|scope| { + let writers: Vec<_> = (0..WRITERS) + .map(|writer| { + let peer = fixture.peers[writer]; + scope.spawn(move || { + // A simple generator; each writer its own sequence. + let mut state = 0x9e37_79b9_7f4a_7c15u64.wrapping_mul(writer as u64 + 1); + let mut next = || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + for _ in 0..30_000 { + let p = nth_prefix((next() % u64::from(PREFIXES)) as u32); + if next() % 3 == 0 { + table.withdraw(peer, &[p]); + } else { + let route = Route { + med: Some((next() % 3) as u32), + ..Route::via( + &[(next() % 3) as u32 + 1, 9][..(next() % 2 + 1) as usize], + ) + }; + let attrs = table.attrs().intern(&route.attributes(), None).unwrap(); + table.announce(peer, &attrs, &[p]); + } + } + }) + }) + .collect(); + + let mirror = &mut mirror; + let done = &done; + let reading = scope.spawn(move || { + let mut reader = table.reader(); + while !done.load(Ordering::Acquire) { + reader.poll(50, |event| mirror.apply(event)); + } + mirror.catch_up(&mut reader, 50); + }); + for writer in writers { + writer.join().unwrap(); + } + done.store(true, Ordering::Release); + reading.join().unwrap(); + }); + + mirror.assert_matches(table); + assert!(mirror.best_events > 0); +} + +// --------------------------------------------------------------------------- +// Readers against the table, under any interleaving +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +enum Op { + Announce { + peer: usize, + prefix: usize, + path_id: u32, + route: Route, + }, + Withdraw { + peer: usize, + prefix: usize, + path_id: u32, + }, + Flap { + peer: usize, + }, + Poll { + reader: usize, + budget: usize, + }, + /// The reader is dropped and a new one takes its place. + Replace { + reader: usize, + }, +} + +const PEERS: usize = 4; +const PREFIXES: usize = 12; +// The first three follow the best routes, the others all routes. +const READERS: usize = 5; +const OF_BEST: usize = 3; + +fn op() -> impl Strategy { + prop_oneof![ + 8 => (0..PEERS, 0..PREFIXES, 0u32..3, route()).prop_map( + |(peer, prefix, path_id, route)| Op::Announce { peer, prefix, path_id, route } + ), + 5 => (0..PEERS, 0..PREFIXES, 0u32..3) + .prop_map(|(peer, prefix, path_id)| Op::Withdraw { peer, prefix, path_id }), + 1 => (0..PEERS).prop_map(|peer| Op::Flap { peer }), + // Reader 0 is polled rarely: it is the one that falls behind. + 4 => (1..READERS, 1usize..6).prop_map(|(reader, budget)| Op::Poll { reader, budget }), + 1 => (0..READERS, 1usize..6).prop_map(|(reader, budget)| Op::Poll { reader, budget }), + 1 => (1..READERS).prop_map(|reader| Op::Replace { reader }), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(1500))] + + /// However changes and reads are interleaved, and however small the + /// journals: a reader that has caught up holds exactly the table's best + /// routes, and every reader gets there in the end. + #[test] + fn a_reader_that_has_caught_up_has_what_the_table_has( + ops in proptest::collection::vec(op(), 1..150), + shards in 1usize..4, + ) { + let infos: Vec = (0..PEERS) + .map(|i| if i == 0 { internal(9) } else { external(i as u8) }) + .collect(); + // Journals that are tidied, and found too long, almost at once. + let limits = Limits { tidy_at: 3, slack: 1 }; + let mut fixture = Fixture::on(RibTable::with_journal_limits(shards, limits), &infos); + let prefixes: Vec = (0..PREFIXES as u32).map(nth_prefix).collect(); + let new_reader = |table: &Arc, index: usize| { + if index < OF_BEST { table.reader() } else { table.reader_of_all_routes() } + }; + let check = |index: usize, mirror: &Mirror, table: &RibTable| { + if index < OF_BEST { + mirror.assert_matches(table); + } else { + mirror.assert_has_all_routes_of(table); + } + }; + let mut readers: Vec<(Reader, Mirror)> = (0..READERS) + .map(|index| (new_reader(&fixture.table, index), Mirror::default())) + .collect(); + + for op in ops { + match op { + Op::Announce { peer, prefix, path_id, route } => { + fixture.announce_path(peer, &prefixes[prefix], path_id, &route); + } + Op::Withdraw { peer, prefix, path_id } => { + fixture.withdraw_path(peer, &prefixes[prefix], path_id); + } + Op::Flap { peer } => { + fixture.table.remove_peer(fixture.peers[peer]); + fixture.peers[peer] = fixture.table.add_peer(infos[peer]).unwrap(); + } + Op::Poll { reader: index, budget } => { + let (reader, mirror) = &mut readers[index]; + let progress = reader.poll(budget, |event| mirror.apply(event)); + if progress.caught_up { + check(index, mirror, &fixture.table); + } + } + Op::Replace { reader } => { + readers[reader] = (new_reader(&fixture.table, reader), Mirror::default()); + } + } + // Never much longer than the table, whatever the readers do. + // Two journals to a shard. + let bound = 2 * shards * (2 * (PREFIXES + limits.slack) + limits.tidy_at + PREFIXES); + prop_assert!(fixture.table.journal_len() <= bound); + } + + for (index, (reader, mirror)) in readers.iter_mut().enumerate() { + mirror.catch_up(reader, 5); + check(index, mirror, &fixture.table); + prop_assert_eq!(mirror.resets, mirror.synced); + } + } +} diff --git a/crates/rib/src/table/mod.rs b/crates/rib/src/table/mod.rs new file mode 100644 index 0000000..dc71ae7 --- /dev/null +++ b/crates/rib/src/table/mod.rs @@ -0,0 +1,728 @@ +//! The routing table: every route every peer has announced, and for each +//! prefix the one chosen as best. +//! +//! This is the Adj-RIBs-In and the Loc-RIB of RFC 4271 §3.2 in one +//! structure, organised by prefix: a prefix's row holds the routes for it +//! from all peers, side by side, and which of them is best. Choosing again +//! after a change looks at one row and nothing else. +//! +//! It is built for a table of hundreds of full feeds, hundreds of millions +//! of routes, taken in on many cores at once: +//! +//! - **A route is small**: sixteen bytes. A pointer to its attribute set, +//! which is shared with every other route that has the same attributes +//! ([`attrs`]), and one word that holds the peer it is from and the first +//! steps of route selection as a single number to compare. +//! - **The table is in shards**, by hash of the prefix, each behind its own +//! lock. A session applies an UPDATE itself, on its own thread, taking +//! the locks of the few shards its prefixes fall in. There is no queue in +//! front of the table and no thread that owns it; sessions that touch +//! different shards do not wait for each other, and one that has to wait +//! is slowed down, which is the back-pressure that is wanted. +//! - **Readers do not stop the table.** Reading takes a shard's lock for +//! reading, one shard at a time, and looks at the rows in place. +//! - **Followers go at their own pace.** What acts on the best routes reads +//! the changes from a [`journal`], and a slow reader holds nothing up. + +pub mod attrs; +mod journal; +mod select; +#[cfg(test)] +mod tests; + +use std::hash::BuildHasher; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::atomic::{AtomicU64, AtomicUsize}; +use std::sync::{Arc, Mutex, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use foldhash::fast::RandomState; +use hashbrown::HashTable; +use hashbrown::hash_table::Entry; +use tokio::sync::Notify; + +pub use attrs::{AttrSet, AttrStore, SelectionKey}; +pub use journal::{Event, Progress, Reader, Routes}; + +/// What route selection needs to know about a peer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PeerInfo { + pub asn: u32, + /// The peer's BGP Identifier. + pub router_id: Ipv4Addr, + pub address: IpAddr, + /// Whether the peer is in our own AS. + pub ibgp: bool, +} + +/// A peer's number in a table, given out by [`RibTable::add_peer`]. Small, +/// because every route carries one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct PeerIndex(u16); + +impl PeerIndex { + pub fn as_usize(self) -> usize { + self.0.into() + } +} + +/// The LOCAL_PREF of a route that does not carry one: every route from an +/// external peer, until import policy says otherwise. +pub const DEFAULT_LOCAL_PREF: u32 = 100; + +/// What a change to the table did. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Outcome { + /// Routes that were not in the table before. + pub added: usize, + /// Routes that took the place of one from the same peer. + pub replaced: usize, + /// Routes that were taken out. + pub removed: usize, + /// Prefixes whose best route is now another one, or none. + pub best_changed: usize, +} + +impl std::ops::AddAssign for Outcome { + fn add_assign(&mut self, other: Self) { + self.added += other.added; + self.replaced += other.replaced; + self.removed += other.removed; + self.best_changed += other.best_changed; + } +} + +/// One route of a prefix: sixteen bytes. +struct Path { + /// Two things in one word. The high 48 bits are the first steps of + /// route selection as one number, the larger the better; see + /// [`select::preference`]. The low 16 are the peer the route is from. + word: u64, + attrs: Arc, +} + +impl Path { + fn new(preference: u64, peer: PeerIndex, attrs: &Arc) -> Self { + Self { + word: (preference << 16) | u64::from(peer.0), + attrs: Arc::clone(attrs), + } + } + + fn preference(&self) -> u64 { + self.word >> 16 + } + + fn peer(&self) -> PeerIndex { + PeerIndex(self.word as u16) + } +} + +/// Everything about one prefix. +struct Row { + prefix: IpPrefix, + /// Index of the best route in `paths`. A row always has at least one + /// route, and so always a best one; and the best one always has the + /// highest [`Path::preference`] in the row, which is what lets most + /// changes be settled without looking at the other routes + /// ([`select`]). + best: u32, + /// The sequence numbers of the latest change of this row's best route + /// and of any of its routes, by which the journals tell their entries + /// for it that still say something from those that do not. + seqs: journal::Seqs, + /// The routes, in one run of memory: finding a peer's route and + /// choosing the best are scans of it. One from each peer that has + /// announced the prefix, or several from a peer that sends more than one + /// (ADD-PATH, RFC 7911). + paths: Vec, + /// The path identifier of each route in `paths`, for telling apart the + /// routes of a peer that sends several. Empty while every route has + /// the identifier zero, which is all routes of peers that send one + /// each: they pay nothing for this. + path_ids: Vec, +} + +impl Row { + fn new(prefix: IpPrefix, path: Path, path_id: u32, seqs: journal::Seqs) -> Self { + Self { + prefix, + best: 0, + seqs, + paths: vec![path], + path_ids: if path_id == 0 { vec![] } else { vec![path_id] }, + } + } + + fn path_id(&self, index: usize) -> u32 { + self.path_ids.get(index).copied().unwrap_or(0) + } + + /// Where the route from `peer` with the identifier `path_id` is. + fn position(&self, peer: PeerIndex, path_id: u32) -> Option { + self.paths + .iter() + .enumerate() + .position(|(index, path)| path.peer() == peer && self.path_id(index) == path_id) + } + + /// Where a route from `peer` is, whatever its identifier. + fn position_of_peer(&self, peer: PeerIndex) -> Option { + self.paths.iter().position(|path| path.peer() == peer) + } + + fn push(&mut self, path: Path, path_id: u32) { + if path_id != 0 && self.path_ids.is_empty() { + self.path_ids = vec![0; self.paths.len()]; + } + push_tight(&mut self.paths, path); + if !self.path_ids.is_empty() { + push_tight(&mut self.path_ids, path_id); + } + } + + /// Takes out the route at `at`; the last route moves into its place. + fn swap_remove(&mut self, at: usize) -> Path { + if !self.path_ids.is_empty() { + self.path_ids.swap_remove(at); + } + self.paths.swap_remove(at) + } + + fn best(&self) -> usize { + self.best as usize + } + + fn set_best(&mut self, index: usize) { + // An index into `paths`, which cannot have that many: each is + // sixteen bytes. + self.best = index as u32; + } + + fn route(&self, index: usize) -> RouteRef<'_> { + let path = &self.paths[index]; + RouteRef { + prefix: self.prefix, + peer: path.peer(), + path_id: self.path_id(index), + attrs: &path.attrs, + best: index == self.best(), + } + } + + fn routes(&self) -> impl Iterator> { + (0..self.paths.len()).map(|index| self.route(index)) + } +} + +/// A route in the table, looked at in place. +#[derive(Debug, Clone, Copy)] +pub struct RouteRef<'a> { + pub prefix: IpPrefix, + /// The peer the route is from. + pub peer: PeerIndex, + /// The peer's identifier for the route: zero unless the peer sends + /// several routes for a prefix (ADD-PATH, RFC 7911). + pub path_id: u32, + pub attrs: &'a Arc, + /// Whether it is the route chosen for its prefix. + pub best: bool, +} + +impl RouteRef<'_> { + /// The route, to keep. + pub fn to_owned(&self) -> Route { + Route { + prefix: self.prefix, + peer: self.peer, + path_id: self.path_id, + attrs: Arc::clone(self.attrs), + best: self.best, + } + } +} + +/// A route taken out of the table to keep; see [`RouteRef`]. +#[derive(Debug, Clone)] +pub struct Route { + pub prefix: IpPrefix, + pub peer: PeerIndex, + pub path_id: u32, + pub attrs: Arc, + pub best: bool, +} + +struct Shard { + rows: HashTable, + routes: usize, + /// A copy of the peer list, so that route selection reads it under the + /// lock it already holds. + peers: Vec>, + hasher: RandomState, + /// Reused between selections. + scratch: select::Scratch, + /// Which prefixes have changed, for those who follow. + journals: journal::Journals, +} + +/// The routing table. Shared between threads by reference; every method +/// takes `&self`. +pub struct RibTable { + shards: Box<[RwLock]>, + hasher: RandomState, + attrs: AttrStore, + /// Which peer numbers are in use. Changing the peer list also takes + /// every shard's lock in turn; this serialises that. + peers: Mutex>>, + /// Which reader slots are in use; see [`journal`]. + reader_slots: Mutex>, + /// How many readers there are, to skip waking them when there are none. + readers: AtomicUsize, + /// Goes up whenever something is written to a journal that has + /// readers. + version: AtomicU64, + wake: Notify, +} + +/// No more peers can be added: every [`PeerIndex`] is in use. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("the table has no room for another peer")] +pub struct TooManyPeers; + +impl RibTable { + /// A table in `shards` shards. More shards let more sessions write at + /// once; a few times the number of cores is plenty. + pub fn new(shards: usize) -> Self { + Self::with_journal_limits(shards, journal::Limits::default()) + } + + /// A table whose journals are tidied, and found too long, at the given + /// sizes. Tests use small ones to get there quickly. + fn with_journal_limits(shards: usize, limits: journal::Limits) -> Self { + let hasher = RandomState::default(); + Self { + shards: (0..shards.max(1)) + .map(|_| { + RwLock::new(Shard { + rows: HashTable::new(), + routes: 0, + peers: Vec::new(), + hasher: hasher.clone(), + scratch: select::Scratch::default(), + journals: journal::Journals::new(limits), + }) + }) + .collect(), + hasher, + attrs: AttrStore::new(), + peers: Mutex::default(), + reader_slots: Mutex::default(), + readers: AtomicUsize::new(0), + version: AtomicU64::new(0), + wake: Notify::new(), + } + } + + /// Where attribute sets for this table's routes are made. + pub fn attrs(&self) -> &AttrStore { + &self.attrs + } + + /// Makes room for a peer's routes and returns the number to announce + /// them under. + /// + /// The number is the peer's until [`RibTable::remove_peer`] is called + /// with it, and may then be given to another. Whoever was given it must + /// not use it after that: the table cannot tell a late announcement + /// from one of the number's next owner. + pub fn add_peer(&self, info: PeerInfo) -> Result { + let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); + let index = match peers.iter().position(Option::is_none) { + Some(free) => free, + None => { + // A peer number shares a word with other things; see `Path`. + if peers.len() > usize::from(u16::MAX) { + return Err(TooManyPeers); + } + peers.push(None); + peers.len() - 1 + } + }; + peers[index] = Some(info); + for shard in &self.shards { + let mut shard = write(shard); + if shard.peers.len() <= index { + shard.peers.resize(index + 1, None); + } + shard.peers[index] = Some(info); + } + Ok(PeerIndex(index as u16)) + } + + /// Takes out every route from `peer` and gives its number back for + /// reuse (RFC 4271 §8.2.2: the routes of a session that ended are + /// deleted). + /// + /// This looks at every prefix in the table. The list of peers is not + /// held while it does, so other peers can come and go meanwhile. + pub fn remove_peer(&self, peer: PeerIndex) -> Outcome { + let mut outcome = Outcome::default(); + let mut news = false; + for shard in &self.shards { + let mut shard = write(shard); + let before = shard.journals.mark(); + outcome += shard.remove_peer(peer); + // From here on the shard takes no more routes from the peer. + if let Some(slot) = shard.peers.get_mut(peer.as_usize()) { + *slot = None; + } + shard.tidy_journals(); + news |= shard.journals.has_news_since(before); + } + // Only now can the number be given out again: no shard knows it. + let mut peers = self.peers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(slot) = peers.get_mut(peer.as_usize()) { + *slot = None; + } + drop(peers); + if news { + self.wake_readers(); + } + outcome + } + + /// Announces `routes` from `peer`, all with the attributes `attrs`: + /// what one UPDATE does. A route is known by its prefix and path + /// identifier together ([`Nlri`]; a plain [`IpPrefix`] is the route + /// with identifier zero, which is every route of a peer that sends one + /// for each prefix). Announcing a route the peer had announced before + /// replaces it. + pub fn announce(&self, peer: PeerIndex, attrs: &Arc, routes: &[N]) -> Outcome + where + N: Copy + Into, + { + let mut outcome = Outcome::default(); + self.by_shard(routes, |shard, placed| { + let Some(info) = shard.peers.get(peer.as_usize()).copied().flatten() else { + // The peer was removed while this was on its way. + return; + }; + let preference = select::preference(attrs.key(), &info); + for (hash, nlri) in placed { + outcome += shard.announce(*hash, *nlri, peer, preference, attrs); + } + }); + outcome + } + + /// Withdraws `routes` of `peer`. Withdrawing a route that is not there + /// does nothing. + pub fn withdraw(&self, peer: PeerIndex, routes: &[N]) -> Outcome + where + N: Copy + Into, + { + let mut outcome = Outcome::default(); + self.by_shard(routes, |shard, placed| { + for (hash, nlri) in placed { + outcome += shard.withdraw(*hash, nlri, peer); + } + }); + outcome + } + + fn shard_of(&self, hash: u64) -> usize { + spread(hash, self.shards.len()) + } + + /// Calls `apply` once for each shard that some of `routes` fall in, + /// with that shard locked and those routes. Wakes the readers if a + /// journal one of them follows was written to. + fn by_shard(&self, routes: &[N], mut apply: impl FnMut(&mut Shard, &[(u64, Nlri)])) + where + N: Copy + Into, + { + let Some(first) = routes.first() else { + return; + }; + let hashed = |route: &N| { + let nlri: Nlri = (*route).into(); + (self.hasher.hash_one(nlri.prefix), nlri) + }; + // An UPDATE has a handful of prefixes; they are sorted on the stack. + const INLINE: usize = 32; + let mut inline = [hashed(first); INLINE]; + let mut spilled = Vec::new(); + let placed: &mut [(u64, Nlri)] = if routes.len() <= INLINE { + for (slot, route) in inline.iter_mut().zip(routes) { + *slot = hashed(route); + } + &mut inline[..routes.len()] + } else { + spilled.extend(routes.iter().map(hashed)); + &mut spilled + }; + placed.sort_unstable_by_key(|(hash, _)| self.shard_of(*hash)); + + let mut news = false; + for run in placed.chunk_by(|a, b| self.shard_of(a.0) == self.shard_of(b.0)) { + let mut shard = write(&self.shards[self.shard_of(run[0].0)]); + let before = shard.journals.mark(); + apply(&mut shard, run); + shard.tidy_journals(); + news |= shard.journals.has_news_since(before); + } + if news { + self.wake_readers(); + } + } + + /// How many routes the table holds, from all peers. + pub fn len(&self) -> usize { + self.shards.iter().map(|shard| read(shard).routes).sum() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// How many prefixes have at least one route. + pub fn prefix_count(&self) -> usize { + self.shards.iter().map(|shard| read(shard).rows.len()).sum() + } + + /// The best route for `prefix`: who it is from, and its attributes. + pub fn best(&self, prefix: &IpPrefix) -> Option<(PeerIndex, Arc)> { + self.with_row(prefix, |row| { + let best = &row.paths[row.best()]; + (best.peer(), Arc::clone(&best.attrs)) + }) + } + + /// Every route for `prefix`. + pub fn routes(&self, prefix: &IpPrefix) -> Vec { + self.with_row(prefix, |row| row.routes().map(|r| r.to_owned()).collect()) + .unwrap_or_default() + } + + fn with_row(&self, prefix: &IpPrefix, read_row: impl FnOnce(&Row) -> T) -> Option { + let hash = self.hasher.hash_one(prefix); + let shard = read(&self.shards[self.shard_of(hash)]); + shard + .rows + .find(hash, |row| row.prefix == *prefix) + .map(read_row) + } + + /// Calls `visit` with the best route of every prefix, in no particular + /// order. The table is not stopped: one shard at a time is held for + /// reading, so the routes seen are not all of one moment. + /// + /// `visit` is called with a shard held, and must not call into the + /// table itself: taking the shard's lock again can wait for ever on a + /// writer that is waiting for this hold to end. + pub fn for_each_best(&self, mut visit: impl FnMut(&IpPrefix, PeerIndex, &Arc)) { + for shard in &self.shards { + for row in &read(shard).rows { + let best = &row.paths[row.best()]; + visit(&row.prefix, best.peer(), &best.attrs); + } + } + } + + /// Up to `limit` routes: every route of as many prefixes as fit, in no + /// particular order. For showing some of a table that is too large to + /// show all of. + pub fn some_routes(&self, limit: usize) -> Vec { + let mut routes = Vec::new(); + for shard in &self.shards { + for row in &read(shard).rows { + for route in row.routes() { + if routes.len() == limit { + return routes; + } + routes.push(route.to_owned()); + } + } + } + routes + } + + /// Calls `visit` with every route in the table. As + /// [`RibTable::for_each_best`]: the table is not stopped, and `visit` + /// must not call into it. + pub fn for_each_route(&self, mut visit: impl FnMut(RouteRef<'_>)) { + for shard in &self.shards { + for row in &read(shard).rows { + row.routes().for_each(&mut visit); + } + } + } +} + +fn read(shard: &RwLock) -> RwLockReadGuard<'_, Shard> { + // A panic under the lock cannot leave a row half changed in a way that + // is unsafe to read; the poison is ignored, as for the server's tables. + shard.read().unwrap_or_else(PoisonError::into_inner) +} + +fn write(shard: &RwLock) -> RwLockWriteGuard<'_, Shard> { + shard.write().unwrap_or_else(PoisonError::into_inner) +} + +/// Picks one of `count` buckets by a hash, for spreading keys over shards +/// or lock stripes that each hold a hash table keyed by the same hash. +/// +/// That table takes a key's slot from the low bits of the hash and, from +/// the top seven, the tag it compares before it looks at the key itself +/// (hashbrown). The bucket is taken from bits in between: taken from +/// either end, the keys of one bucket would all share those bits, and the +/// table inside would lose them. +fn spread(hash: u64, count: usize) -> usize { + let middle = (hash << 7) >> 32; + ((middle * count as u64) >> 32) as usize +} + +/// Adds to a vector without the doubling that `push` does: a row of a few +/// hundred routes should not hold room for as many again. Grows by a +/// quarter, which keeps adding cheap on average. +fn push_tight(vec: &mut Vec, value: T) { + if vec.len() == vec.capacity() { + vec.reserve_exact((vec.len() / 4).max(1)); + } + vec.push(value); +} + +impl Shard { + fn tidy_journals(&mut self) { + self.journals.tidy(&self.rows, &self.hasher); + } + + fn announce( + &mut self, + hash: u64, + nlri: Nlri, + peer: PeerIndex, + preference: u64, + attrs: &Arc, + ) -> Outcome { + let Nlri { prefix, path_id } = nlri; + let hasher = &self.hasher; + let entry = self.rows.entry( + hash, + |row| row.prefix == prefix, + |row| hasher.hash_one(row.prefix), + ); + let path = Path::new(preference, peer, attrs); + let row = match entry { + Entry::Vacant(vacant) => { + let seqs = self.journals.appeared(prefix); + vacant.insert(Row::new(prefix, path, path_id, seqs)); + self.routes += 1; + return Outcome { + added: 1, + best_changed: 1, + ..Outcome::default() + }; + } + Entry::Occupied(occupied) => occupied.into_mut(), + }; + + let (outcome, best_changed) = match row.position(peer, path_id) { + None => { + row.push(path, path_id); + self.routes += 1; + let outcome = Outcome { + added: 1, + ..Outcome::default() + }; + ( + outcome, + select::after_add(row, &self.peers, &mut self.scratch), + ) + } + Some(at) => { + let before = std::mem::replace(&mut row.paths[at], path); + let outcome = Outcome { + replaced: 1, + ..Outcome::default() + }; + if Arc::ptr_eq(&before.attrs, attrs) && before.preference() == preference { + // The same route again: nothing changed at all. + return outcome; + } + let best_changed = select::after_replace( + row, + at, + before.preference(), + &self.peers, + &mut self.scratch, + ); + (outcome, best_changed) + } + }; + self.journals.changed(row, best_changed); + Outcome { + best_changed: best_changed.into(), + ..outcome + } + } + + fn withdraw(&mut self, hash: u64, nlri: &Nlri, peer: PeerIndex) -> Outcome { + let prefix = nlri.prefix; + let Ok(mut entry) = self.rows.find_entry(hash, |row| row.prefix == prefix) else { + return Outcome::default(); + }; + let row = entry.get_mut(); + let Some(at) = row.position(peer, nlri.path_id) else { + return Outcome::default(); + }; + self.routes -= 1; + if row.paths.len() == 1 { + entry.remove(); + self.journals.disappeared(prefix); + return Outcome { + removed: 1, + best_changed: 1, + ..Outcome::default() + }; + } + let best_changed = select::after_remove(row, at, &self.peers, &mut self.scratch); + self.journals.changed(row, best_changed); + Outcome { + removed: 1, + best_changed: best_changed.into(), + ..Outcome::default() + } + } + + fn remove_peer(&mut self, peer: PeerIndex) -> Outcome { + let mut outcome = Outcome::default(); + let peers = &self.peers; + let scratch = &mut self.scratch; + let journals = &mut self.journals; + self.rows.retain(|row| { + let mut removed = 0; + let mut best_changed = false; + // A peer that sends several routes for a prefix has several + // here. + while let Some(at) = row.position_of_peer(peer) { + removed += 1; + if row.paths.len() == 1 { + outcome.removed += removed; + outcome.best_changed += 1; + journals.disappeared(row.prefix); + return false; + } + best_changed |= select::after_remove(row, at, peers, scratch); + } + if removed > 0 { + outcome.removed += removed; + outcome.best_changed += usize::from(best_changed); + journals.changed(row, best_changed); + } + true + }); + self.routes -= outcome.removed; + outcome + } +} diff --git a/crates/rib/src/table/select.rs b/crates/rib/src/table/select.rs new file mode 100644 index 0000000..1a70b45 --- /dev/null +++ b/crates/rib/src/table/select.rs @@ -0,0 +1,249 @@ +//! Choosing the best route for a prefix (RFC 4271 §9.1.2.2). +//! +//! The steps, in order. A route wins at the first step where it differs: +//! +//! 1. highest LOCAL_PREF; +//! 2. shortest AS path; +//! 3. lowest ORIGIN; +//! 4. lowest MULTI_EXIT_DISC, among routes from the same neighbouring AS; +//! 5. a route from an external peer over one from an internal peer; +//! 6. the peer with the lowest BGP Identifier; +//! 7. the peer with the lowest address. +//! +//! (The RFC's step between 5 and 6, the interior cost to the next hop, has +//! nothing to compare until there is an IGP to ask.) +//! +//! Steps 1 to 3 are put into one number per route when it is stored, +//! [`preference`]. Most of the time that number alone settles it: a new +//! route with a larger one than the best is the new best, with a smaller +//! one changes nothing, and neither needs a look at any other route. Only a +//! tie goes on to [`choose`], which looks at the whole row, and that begins +//! with a pass over the numbers to find the routes still in the running. +//! +//! [`after_add`], [`after_replace`] and [`after_remove`] are what the table +//! calls when a row has changed. Each says whether the best route is now +//! another, and takes the short way where there is one. They rely on, and +//! keep, one fact about a row: its best route has the highest preference +//! of all its routes. + +use super::attrs::SelectionKey; +use super::{DEFAULT_LOCAL_PREF, PeerInfo, Row}; + +/// Steps 1 to 3 as one number of 48 bits: the larger, the better. +/// +/// LOCAL_PREF fills the high 32 bits. Below it come the AS path length in +/// 12 bits and the ORIGIN in 2, each turned upside down so that shorter and +/// lower compare as larger. Twelve bits are enough for any path: a BGP +/// message of 4096 octets has room for about a thousand AS numbers. +pub(super) fn preference(key: &SelectionKey, peer: &PeerInfo) -> u64 { + // LOCAL_PREF is a matter between routers of one AS. A route from an + // external peer has the default until import policy gives it another. + let local_pref = if peer.ibgp { + key.local_pref.unwrap_or(DEFAULT_LOCAL_PREF) + } else { + DEFAULT_LOCAL_PREF + }; + const LONGEST: u16 = (1 << 12) - 1; + (u64::from(local_pref) << 16) + | (u64::from(LONGEST - key.as_path_len.min(LONGEST)) << 4) + | (u64::from(3 - key.origin.min(3)) << 2) +} + +/// Buffers that [`choose`] reuses. +#[derive(Default)] +pub(super) struct Scratch { + /// Routes still in the running: (neighbouring AS, MED, index in row). + candidates: Vec<(u32, u32, u32)>, +} + +/// The best route changed after a route was added at the end of `row`? +pub(super) fn after_add(row: &mut Row, peers: &[Option], scratch: &mut Scratch) -> bool { + let added = row.paths.len() - 1; + let preference = row.paths[added].preference(); + let best = row.paths[row.best()].preference(); + // Better than the best in the first steps of selection is better than + // everything; worse than it changes nothing. A tie has to be settled by + // the later steps. + if preference > best { + row.set_best(added); + true + } else if preference < best { + false + } else if let Some(wins) = settles_tie(row, added, peers) { + if wins { + row.set_best(added); + } + wins + } else { + choose(row, peers, scratch) + } +} + +/// The best route changed after the route at `at` was replaced by another +/// from the same peer? `before` is the preference of the route it replaced. +/// The two are known to differ. +pub(super) fn after_replace( + row: &mut Row, + at: usize, + before: u64, + peers: &[Option], + scratch: &mut Scratch, +) -> bool { + let preference = row.paths[at].preference(); + if at == row.best() { + // The best route itself changed. If it got better in the first + // steps it is still the best, but it is a different route now. + if preference <= before { + choose(row, peers, scratch); + } + return true; + } + let best = row.paths[row.best()].preference(); + if preference > best { + row.set_best(at); + true + } else if preference < best && before < best { + // It was not in the running and still is not. + false + } else { + choose(row, peers, scratch) + } +} + +/// Takes the route at `at` out of `row`, which has others, and returns +/// whether the best route changed. +pub(super) fn after_remove( + row: &mut Row, + at: usize, + peers: &[Option], + scratch: &mut Scratch, +) -> bool { + let best = row.best(); + let best_preference = row.paths[best].preference(); + let last = row.paths.len() - 1; + let removed = row.swap_remove(at); + if at == best { + choose(row, peers, scratch); + return true; + } + // The last route moved into the gap. + if best == last { + row.set_best(at); + } + // A route that tied with the best in the first steps may have been + // what kept another from being best, by its MED. + removed.preference() == best_preference && choose(row, peers, scratch) +} + +/// Chooses the best route of `row` from all its routes, records it in the +/// row, and returns whether it is another route than before. +/// +/// The row must have at least one route. +fn choose(row: &mut Row, peers: &[Option], scratch: &mut Scratch) -> bool { + let before = row.best; + let top = row + .paths + .iter() + .map(|path| path.preference()) + .max() + .unwrap_or(0); + + let candidates = &mut scratch.candidates; + candidates.clear(); + for (index, path) in row.paths.iter().enumerate() { + if path.preference() == top { + let key = path.attrs.key(); + // A route without a MED is taken to have the lowest (RFC 4271 + // §9.1.2.2 c). + candidates.push((key.neighbor_as, key.med.unwrap_or(0), index as u32)); + } + } + + // Step 4. MEDs are compared only between routes from the same + // neighbouring AS, so "lower MED" is not an order over all the routes, + // and comparing them pair by pair can go round in circles. Instead: + // within each neighbouring AS only the routes with its lowest MED stay + // in, and the steps after are an order over what is left. + candidates.sort_unstable(); + let mut best: Option<(TieBreak, u32)> = None; + let mut group = None; + for &(neighbor_as, med, index) in candidates.iter() { + // Sorted by AS and then MED: the first of each AS has its lowest. + let lowest = match group { + Some((group_as, lowest)) if group_as == neighbor_as => lowest, + _ => { + group = Some((neighbor_as, med)); + med + } + }; + if med != lowest { + continue; + } + let tie_break = TieBreak::of(row, index as usize, peers); + if best.as_ref().is_none_or(|(least, _)| tie_break < *least) { + best = Some((tie_break, index)); + } + } + + row.best = best.map_or(0, |(_, index)| index); + row.best != before +} + +/// For a route just added at `added` that ties with the best in steps 1 to +/// 3: whether it is the new best, if that can be said from the two routes +/// alone. `None` if the whole row has to be looked at. +/// +/// It can be said when MED cannot knock either of them out. The new route +/// has the lowest MED there is, so nothing knocks it out. It knocks out +/// only routes of its own neighbouring AS with a higher MED, and if the +/// best is not one of those, the best is still the best of all the others. +/// So the better of the two is the best of all. This is the usual case: +/// most routes have no MED. +fn settles_tie(row: &Row, added: usize, peers: &[Option]) -> Option { + let new = row.paths[added].attrs.key(); + let best = row.paths[row.best()].attrs.key(); + if new.med.unwrap_or(0) != 0 { + return None; + } + if best.neighbor_as == new.neighbor_as && best.med.unwrap_or(0) != 0 { + return None; + } + Some(TieBreak::of(row, added, peers) < TieBreak::of(row, row.best(), peers)) +} + +/// Steps 5 to 7, then the peer's number, and then the path identifier for +/// a peer that sent several routes: so that the choice never depends on the +/// order routes arrived in. The least wins. +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct TieBreak { + internal: bool, + router_id: u32, + address: std::net::IpAddr, + peer: u16, + path_id: u32, +} + +impl TieBreak { + fn of(row: &Row, index: usize, peers: &[Option]) -> Self { + let peer = row.paths[index].peer(); + let path_id = row.path_id(index); + match peers.get(peer.as_usize()).copied().flatten() { + Some(info) => Self { + internal: info.ibgp, + router_id: info.router_id.into(), + address: info.address, + peer: peer.0, + path_id, + }, + // Not expected: a peer is forgotten only once its routes are + // gone. If it happened, anything known would be preferred. + None => Self { + internal: true, + router_id: u32::MAX, + address: std::net::Ipv6Addr::from(u128::MAX).into(), + peer: peer.0, + path_id, + }, + } + } +} diff --git a/crates/rib/src/table/tests.rs b/crates/rib/src/table/tests.rs new file mode 100644 index 0000000..ceef3af --- /dev/null +++ b/crates/rib/src/table/tests.rs @@ -0,0 +1,759 @@ +use std::collections::{BTreeMap, HashMap}; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; + +use bgp_packet::message::{ + AsPathAttribute, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute, + OriginPathAttribute, PathAttribute, +}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use proptest::prelude::*; + +use super::*; + +pub(super) fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() +} + +/// What a test says about a route; everything selection looks at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Route { + pub(super) local_pref: Option, + pub(super) path: Vec, + pub(super) origin: u8, + pub(super) med: Option, +} + +impl Route { + pub(super) fn via(path: &[u32]) -> Self { + Self { + local_pref: None, + path: path.to_vec(), + origin: 0, + med: None, + } + } + + pub(super) fn attributes(&self) -> Vec { + let mut attributes = vec![ + PathAttribute::Origin(match self.origin { + 0 => OriginPathAttribute::IGP, + 1 => OriginPathAttribute::EGP, + _ => OriginPathAttribute::INCOMPLETE, + }), + AsPathAttribute::from_asns(self.path.clone()), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + ]; + attributes.extend( + self.med + .map(|med| PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(med))), + ); + attributes.extend( + self.local_pref + .map(|lp| PathAttribute::LocalPref(LocalPrefPathAttribute(lp))), + ); + attributes + } +} + +pub(super) fn external(number: u8) -> PeerInfo { + PeerInfo { + asn: 65000 + u32::from(number), + router_id: Ipv4Addr::new(10, 0, 0, number), + address: IpAddr::from(Ipv4Addr::new(192, 0, 2, number)), + ibgp: false, + } +} + +pub(super) fn internal(number: u8) -> PeerInfo { + PeerInfo { + asn: 64999, + ibgp: true, + ..external(number) + } +} + +/// A table and the peers added to it, for tests that speak of peers by the +/// order they were added in. +pub(super) struct Fixture { + pub(super) table: Arc, + pub(super) peers: Vec, +} + +impl Fixture { + pub(super) fn new(peers: &[PeerInfo]) -> Self { + // More than one shard, so that a test's prefixes are spread. + Self::on(RibTable::new(4), peers) + } + + pub(super) fn on(table: RibTable, peers: &[PeerInfo]) -> Self { + let table = Arc::new(table); + let peers = peers.iter().map(|p| table.add_peer(*p).unwrap()).collect(); + Self { table, peers } + } + + pub(super) fn announce(&self, peer: usize, prefix: &IpPrefix, route: &Route) -> Outcome { + let attrs = self + .table + .attrs() + .intern(&route.attributes(), None) + .unwrap(); + self.table.announce(self.peers[peer], &attrs, &[*prefix]) + } + + pub(super) fn withdraw(&self, peer: usize, prefix: &IpPrefix) -> Outcome { + self.table.withdraw(self.peers[peer], &[*prefix]) + } + + /// As `announce`, from a peer that sends several routes for a prefix + /// and calls this one `path_id`. + pub(super) fn announce_path( + &self, + peer: usize, + prefix: &IpPrefix, + path_id: u32, + route: &Route, + ) -> Outcome { + let attrs = self + .table + .attrs() + .intern(&route.attributes(), None) + .unwrap(); + let nlri = Nlri { + prefix: *prefix, + path_id, + }; + self.table.announce(self.peers[peer], &attrs, &[nlri]) + } + + pub(super) fn withdraw_path(&self, peer: usize, prefix: &IpPrefix, path_id: u32) -> Outcome { + let nlri = Nlri { + prefix: *prefix, + path_id, + }; + self.table.withdraw(self.peers[peer], &[nlri]) + } + + /// The best route for `prefix`: which peer (by order added) it is from + /// and that peer's identifier for it. + pub(super) fn best_path(&self, prefix: &IpPrefix) -> Option<(usize, u32)> { + let best = self.table.routes(prefix).into_iter().find(|r| r.best)?; + let peer = self.peers.iter().position(|p| *p == best.peer)?; + Some((peer, best.path_id)) + } + + /// The routes held for `prefix`, as (peer, path identifier), in order. + pub(super) fn held(&self, prefix: &IpPrefix) -> Vec<(usize, u32)> { + let mut held: Vec<(usize, u32)> = self + .table + .routes(prefix) + .iter() + .map(|r| { + let peer = self.peers.iter().position(|p| *p == r.peer).unwrap(); + (peer, r.path_id) + }) + .collect(); + held.sort_unstable(); + held + } + + /// Which peer (by order added) the best route for `prefix` is from. + fn best(&self, prefix: &IpPrefix) -> Option { + let (peer, _) = self.table.best(prefix)?; + self.peers.iter().position(|p| *p == peer) + } +} + +#[test] +fn each_step_of_selection_decides_when_the_ones_before_tie() { + let p = prefix("10.0.0.0/8"); + // Peer 0 has the lower identifier, so it wins whenever nothing else + // decides; each case makes peer 1's route better in one step. + let cases: [(&str, [PeerInfo; 2], Route, Route); 6] = [ + ( + "higher LOCAL_PREF, despite a longer path", + [internal(1), internal(2)], + Route { + local_pref: Some(100), + ..Route::via(&[1]) + }, + Route { + local_pref: Some(200), + ..Route::via(&[1, 2, 3]) + }, + ), + ( + "shorter AS path", + [external(1), external(2)], + Route::via(&[1, 2, 3]), + Route::via(&[4, 5]), + ), + ( + "lower ORIGIN", + [external(1), external(2)], + Route { + origin: 2, + ..Route::via(&[1, 2]) + }, + Route { + origin: 0, + ..Route::via(&[3, 4]) + }, + ), + ( + "lower MED from the same neighbouring AS", + [external(1), external(2)], + Route { + med: Some(20), + ..Route::via(&[7, 2]) + }, + Route { + med: Some(10), + ..Route::via(&[7, 3]) + }, + ), + ( + "no MED counts as the lowest", + [external(1), external(2)], + Route { + med: Some(1), + ..Route::via(&[7, 2]) + }, + Route::via(&[7, 3]), + ), + ( + "external over internal", + [internal(1), external(2)], + Route::via(&[7, 2]), + Route::via(&[8, 3]), + ), + ]; + for (case, peers, first, second) in cases { + // In both orders of arrival: the choice must not depend on it. + for order in [[0, 1], [1, 0]] { + let fixture = Fixture::new(&peers); + for peer in order { + fixture.announce(peer, &p, [&first, &second][peer]); + } + assert_eq!(fixture.best(&p), Some(1), "{case}, arriving {order:?}"); + } + } + + // And when nothing decides: the lowest identifier. + let fixture = Fixture::new(&[external(2), external(1)]); + fixture.announce(0, &p, &Route::via(&[1, 2])); + fixture.announce(1, &p, &Route::via(&[3, 4])); + assert_eq!(fixture.best(&p), Some(1)); +} + +/// MEDs are compared only within a neighbouring AS. Taken pair by pair +/// that is not an order: A beats B, B beats C, C beats A. Whatever the +/// order of arrival, the answer must be the same. +#[test] +fn med_is_compared_only_within_a_neighbouring_as_and_never_goes_in_circles() { + let p = prefix("10.0.0.0/8"); + let routes = [ + // From AS 1, MED 10, and the highest identifier. + Route { + med: Some(10), + ..Route::via(&[1, 9]) + }, + // From AS 2: its MED is not comparable with the others'. + Route { + med: Some(50), + ..Route::via(&[2, 9]) + }, + // From AS 1, MED 20: loses to the first on MED. + Route { + med: Some(20), + ..Route::via(&[1, 8]) + }, + ]; + // Identifiers: the third route's peer lowest, then the second's, then + // the first's. Without MED the third would win; it is knocked out by + // the first, and of the two left the second has the lower identifier. + let peers = [external(3), external(2), external(1)]; + for order in [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] { + let fixture = Fixture::new(&peers); + for peer in order { + fixture.announce(peer, &p, &routes[peer]); + } + assert_eq!(fixture.best(&p), Some(1), "arriving {order:?}"); + } + + // Take away the route that knocked the third out, and the third wins. + let fixture = Fixture::new(&peers); + for (peer, route) in routes.iter().enumerate() { + fixture.announce(peer, &p, route); + } + let outcome = fixture.withdraw(0, &p); + assert_eq!(outcome.best_changed, 1); + assert_eq!(fixture.best(&p), Some(2)); +} + +/// A route that is not the best can still decide which is: by its MED it +/// keeps another route of its neighbouring AS out. When it gets worse or +/// goes, that other route is back in the running. The shortcuts must not +/// skip this. +#[test] +fn a_route_that_was_keeping_another_out_matters_when_it_changes() { + let p = prefix("10.0.0.0/8"); + // Peer 0: from AS 1 with the lowest identifier, but MED 1. + // Peer 1: from AS 2. Best while peer 0's route is kept out. + // Peer 2: from AS 1 with MED 0, which keeps peer 0's route out. + let peers = [external(1), external(2), external(3)]; + let held_out = Route { + med: Some(1), + ..Route::via(&[1, 9]) + }; + let best = Route::via(&[2, 9]); + let holding_out = Route { + med: Some(0), + ..Route::via(&[1, 8]) + }; + let set_up = || { + let fixture = Fixture::new(&peers); + fixture.announce(0, &p, &held_out); + fixture.announce(1, &p, &best); + fixture.announce(2, &p, &holding_out); + assert_eq!(fixture.best(&p), Some(1)); + fixture + }; + + // It is replaced by a route with a longer path. + let fixture = set_up(); + let outcome = fixture.announce(2, &p, &Route::via(&[1, 8, 7])); + assert_eq!(fixture.best(&p), Some(0)); + assert_eq!(outcome.best_changed, 1); + + // It is withdrawn. + let fixture = set_up(); + let outcome = fixture.withdraw(2, &p); + assert_eq!(fixture.best(&p), Some(0)); + assert_eq!(outcome.best_changed, 1); + + // Its peer goes away. + let fixture = set_up(); + let outcome = fixture.table.remove_peer(fixture.peers[2]); + assert_eq!(fixture.best(&p), Some(0)); + assert_eq!(outcome.best_changed, 1); + + // Its MED gets worse than the one it was keeping out. + let fixture = set_up(); + fixture.announce( + 2, + &p, + &Route { + med: Some(5), + ..Route::via(&[1, 8]) + }, + ); + assert_eq!(fixture.best(&p), Some(0)); +} + +#[test] +fn what_a_change_did_is_reported() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + let long = Route::via(&[1, 2, 3]); + let short = Route::via(&[4]); + + let first = fixture.announce(0, &p, &long); + assert_eq!((first.added, first.best_changed), (1, 1)); + let worse = fixture.announce(1, &p, &Route::via(&[1, 2, 3, 4])); + assert_eq!((worse.added, worse.best_changed), (1, 0)); + // The same route again changes nothing. + let again = fixture.announce(0, &p, &long); + assert_eq!((again.replaced, again.best_changed), (1, 0)); + // The best route replaced by a better one is still a change of best. + let better = fixture.announce(0, &p, &short); + assert_eq!((better.replaced, better.best_changed), (1, 1)); + assert_eq!((fixture.table.len(), fixture.table.prefix_count()), (2, 1)); + + assert_eq!(fixture.withdraw(1, &p).best_changed, 0); + let last = fixture.withdraw(0, &p); + assert_eq!((last.removed, last.best_changed), (1, 1)); + assert_eq!(fixture.withdraw(0, &p), Outcome::default(), "nothing there"); + assert!(fixture.table.is_empty()); + assert_eq!(fixture.best(&p), None); +} + +#[test] +fn removing_a_peer_takes_its_routes_and_frees_its_number() { + let fixture = Fixture::new(&[external(1), external(2)]); + let prefixes: Vec = (0..200u32) + .map(|i| { + IpPrefix::new( + bgp_primitives::constants::IpVersion::Ipv4, + (i << 8).to_be_bytes(), + 24, + ) + .unwrap() + }) + .collect(); + let attrs = |route: &Route| { + fixture + .table + .attrs() + .intern(&route.attributes(), None) + .unwrap() + }; + // Peer 0 is best everywhere; peer 1 has only the first hundred. + fixture + .table + .announce(fixture.peers[0], &attrs(&Route::via(&[1])), &prefixes); + fixture.table.announce( + fixture.peers[1], + &attrs(&Route::via(&[2, 3])), + &prefixes[..100], + ); + assert_eq!(fixture.table.len(), 300); + + let outcome = fixture.table.remove_peer(fixture.peers[0]); + assert_eq!(outcome.removed, 200); + assert_eq!(outcome.best_changed, 200); + assert_eq!( + (fixture.table.len(), fixture.table.prefix_count()), + (100, 100) + ); + assert_eq!(fixture.best(&prefixes[0]), Some(1)); + assert_eq!(fixture.best(&prefixes[150]), None); + + // Its routes are gone, so its attribute set can be dropped. + assert_eq!(fixture.table.attrs().sweep(), 1); + // And its number is given to the next peer. + assert_eq!(fixture.table.add_peer(external(3)), Ok(fixture.peers[0])); +} + +#[test] +fn readers_see_every_route_and_which_is_best() { + let fixture = Fixture::new(&[external(1), external(2)]); + let (a, b) = (prefix("10.0.0.0/8"), prefix("2001:db8::/32")); + fixture.announce(0, &a, &Route::via(&[1, 2])); + fixture.announce(1, &a, &Route::via(&[3])); + fixture.announce(0, &b, &Route::via(&[1, 2])); + + let mut best = Vec::new(); + fixture + .table + .for_each_best(|prefix, peer, _| best.push((*prefix, peer))); + best.sort_by_key(|(prefix, _)| prefix.to_string()); + assert_eq!(best, [(a, fixture.peers[1]), (b, fixture.peers[0])]); + + let mut all = 0; + let mut marked_best = 0; + fixture.table.for_each_route(|route| { + all += 1; + marked_best += usize::from(route.best); + }); + assert_eq!((all, marked_best), (3, 2)); + + let routes = fixture.table.routes(&a); + assert_eq!(routes.len(), 2); + assert!( + routes + .iter() + .any(|route| route.peer == fixture.peers[1] && route.best) + ); + // Routes with the same attributes share one set. + let shared = &fixture.table.routes(&b)[0].attrs; + assert!(routes.iter().any(|route| Arc::ptr_eq(&route.attrs, shared))); +} + +/// With ADD-PATH a peer sends several routes for one prefix and tells them +/// apart by a path identifier. Each is a route of its own: kept, replaced +/// and withdrawn by prefix and identifier together. +#[test] +fn a_peer_can_have_several_routes_for_a_prefix() { + let fixture = Fixture::new(&[external(1), external(2)]); + let p = prefix("10.0.0.0/8"); + + fixture.announce_path(0, &p, 1, &Route::via(&[1, 2, 3])); + let second = fixture.announce_path(0, &p, 2, &Route::via(&[1, 4])); + assert_eq!( + (second.added, second.replaced, second.best_changed), + (1, 0, 1) + ); + fixture.announce(1, &p, &Route::via(&[2, 5, 6, 7])); + assert_eq!(fixture.held(&p), [(0, 1), (0, 2), (1, 0)]); + assert_eq!(fixture.best_path(&p), Some((0, 2)), "the shorter path"); + assert_eq!((fixture.table.len(), fixture.table.prefix_count()), (3, 1)); + + // Announcing a path again replaces that path and no other. + let replaced = fixture.announce_path(0, &p, 1, &Route::via(&[1])); + assert_eq!((replaced.added, replaced.replaced), (0, 1)); + assert_eq!(fixture.held(&p), [(0, 1), (0, 2), (1, 0)]); + assert_eq!(fixture.best_path(&p), Some((0, 1))); + + // Withdrawing one leaves the peer's other. + assert_eq!(fixture.withdraw_path(0, &p, 1).removed, 1); + assert_eq!(fixture.held(&p), [(0, 2), (1, 0)]); + assert_eq!(fixture.best_path(&p), Some((0, 2))); + // A path that is not there is not withdrawn: not the same prefix + // under another identifier, and not the peer's plain route. + assert_eq!(fixture.withdraw_path(0, &p, 9), Outcome::default()); + assert_eq!(fixture.withdraw(0, &p), Outcome::default()); + + // When the session ends, all of the peer's routes go. + fixture.announce_path(0, &p, 3, &Route::via(&[1, 8, 9])); + let outcome = fixture.table.remove_peer(fixture.peers[0]); + assert_eq!((outcome.removed, outcome.best_changed), (2, 1)); + assert_eq!(fixture.held(&p), [(1, 0)]); +} + +/// Two routes from one peer that nothing else tells apart: the lower path +/// identifier is chosen, whichever arrived first. +#[test] +fn equal_routes_from_one_peer_are_chosen_by_path_identifier() { + let p = prefix("10.0.0.0/8"); + for order in [[5, 3], [3, 5]] { + let fixture = Fixture::new(&[external(1)]); + for path_id in order { + fixture.announce_path(0, &p, path_id, &Route::via(&[1, path_id])); + } + assert_eq!(fixture.best_path(&p), Some((0, 3)), "arriving {order:?}"); + } +} + +// --------------------------------------------------------------------------- +// The table against a model +// --------------------------------------------------------------------------- + +/// Route selection written the plain way: all routes of a prefix, step by +/// step, with no shortcuts. The table takes shortcuts and must agree. +/// Routes are known by peer and path identifier. +fn chosen_plainly( + routes: &BTreeMap<(usize, u32), Route>, + peers: &[PeerInfo], +) -> Option<(usize, u32)> { + let local_pref = |peer: usize, route: &Route| { + if peers[peer].ibgp { + route.local_pref.unwrap_or(DEFAULT_LOCAL_PREF) + } else { + DEFAULT_LOCAL_PREF + } + }; + let mut left: Vec<((usize, u32), &Route)> = + routes.iter().map(|(k, route)| (*k, route)).collect(); + let most = left.iter().map(|(k, r)| local_pref(k.0, r)).max()?; + left.retain(|(k, r)| local_pref(k.0, r) == most); + let shortest = left.iter().map(|(_, r)| r.path.len()).min()?; + left.retain(|(_, r)| r.path.len() == shortest); + let lowest = left.iter().map(|(_, r)| r.origin).min()?; + left.retain(|(_, r)| r.origin == lowest); + + let neighbor = |route: &Route| route.path.first().copied().unwrap_or(0); + let med = |route: &Route| route.med.unwrap_or(0); + let mut lowest_med: HashMap = HashMap::new(); + for (_, route) in &left { + let entry = lowest_med.entry(neighbor(route)).or_insert(u32::MAX); + *entry = (*entry).min(med(route)); + } + left.retain(|(_, r)| med(r) == lowest_med[&neighbor(r)]); + + left.into_iter() + .map(|(key, _)| key) + .min_by_key(|&(peer, path_id)| { + ( + peers[peer].ibgp, + peers[peer].router_id, + peers[peer].address, + peer, + path_id, + ) + }) +} + +#[derive(Debug, Clone)] +enum Op { + Announce { + peer: usize, + prefix: usize, + path_id: u32, + route: Route, + }, + Withdraw { + peer: usize, + prefix: usize, + path_id: u32, + }, + /// The session goes down and comes straight back. + Flap { peer: usize }, +} + +const PEERS: usize = 6; +// Few prefixes, so that each gathers routes from most peers. +const PREFIXES: usize = 2; +// Most routes have the identifier zero, as without ADD-PATH; some peers +// send a second and third route. +const PATH_IDS: u32 = 3; + +pub(super) fn route() -> impl Strategy { + // Few values of everything, so that ties at every step are common: + // ties are where the shortcuts could be wrong. + ( + proptest::option::of(100u32..102), + proptest::collection::vec(1u32..3, 1..3), + 0u8..2, + proptest::option::of(0u32..3), + ) + .prop_map(|(local_pref, path, origin, med)| Route { + local_pref, + path, + origin, + med, + }) +} + +fn path_id() -> impl Strategy { + prop_oneof![3 => Just(0), 2 => 1..PATH_IDS] +} + +fn op() -> impl Strategy { + prop_oneof![ + 6 => (0..PEERS, 0..PREFIXES, path_id(), route()).prop_map( + |(peer, prefix, path_id, route)| Op::Announce { peer, prefix, path_id, route } + ), + 3 => (0..PEERS, 0..PREFIXES, path_id()) + .prop_map(|(peer, prefix, path_id)| Op::Withdraw { peer, prefix, path_id }), + 1 => (0..PEERS).prop_map(|peer| Op::Flap { peer }), + ] +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(2000))] + + /// After any sequence of changes the table holds exactly the routes it + /// should, has chosen for every prefix what plain selection chooses, + /// and has reported a change of best route exactly when there was one. + #[test] + fn the_table_agrees_with_plain_selection(ops in proptest::collection::vec(op(), 1..80)) { + // Internal and external peers, and identifiers that are not in the + // order the peers are added in. + let infos: Vec = (0..PEERS) + .map(|i| { + let number = (PEERS - i) as u8; + if i % 3 == 0 { internal(number) } else { external(number) } + }) + .collect(); + let mut fixture = Fixture::new(&infos); + let prefixes: Vec = (0..PREFIXES) + .map(|i| prefix(&format!("10.{i}.0.0/16"))) + .collect(); + let mut model: Vec> = vec![BTreeMap::new(); PREFIXES]; + + for op in ops { + let before: Vec> = model + .iter() + .map(|routes| chosen_plainly(routes, &infos).map(|key| (key, routes[&key].clone()))) + .collect(); + let outcome = match &op { + Op::Announce { peer, prefix, path_id, route } => { + model[*prefix].insert((*peer, *path_id), route.clone()); + fixture.announce_path(*peer, &prefixes[*prefix], *path_id, route) + } + Op::Withdraw { peer, prefix, path_id } => { + model[*prefix].remove(&(*peer, *path_id)); + fixture.withdraw_path(*peer, &prefixes[*prefix], *path_id) + } + Op::Flap { peer } => { + for routes in &mut model { + routes.retain(|(from, _), _| from != peer); + } + let outcome = fixture.table.remove_peer(fixture.peers[*peer]); + fixture.peers[*peer] = fixture.table.add_peer(infos[*peer]).unwrap(); + outcome + } + }; + + let mut best_changes = 0; + for (i, routes) in model.iter().enumerate() { + let expected = chosen_plainly(routes, &infos); + prop_assert_eq!(fixture.best_path(&prefixes[i]), expected, "after {:?}", &op); + let now = expected.map(|key| (key, routes[&key].clone())); + best_changes += usize::from(now != before[i]); + prop_assert_eq!( + fixture.held(&prefixes[i]), + routes.keys().copied().collect::>() + ); + } + prop_assert_eq!(outcome.best_changed, best_changes, "after {:?}", &op); + prop_assert_eq!( + fixture.table.len(), + model.iter().map(BTreeMap::len).sum::() + ); + } + } +} + +/// Shards and lock stripes are picked by bits of the hash that the hash +/// table inside does not use, so that keys which share a shard still +/// differ where that table looks. +#[test] +fn keys_of_one_shard_differ_in_the_bits_their_table_uses() { + let hasher = RandomState::default(); + let shards = 64; + let mut counts = vec![0usize; shards]; + let mut tags = vec![std::collections::HashSet::new(); shards]; + for n in 0..64_000u32 { + let hash = hasher.hash_one(n); + let shard = spread(hash, shards); + counts[shard] += 1; + // The seven bits hashbrown keeps as a tag. + tags[shard].insert(hash >> 57); + } + for shard in 0..shards { + assert!( + (700..1300).contains(&counts[shard]), + "shard {shard} has {} of 64000 keys", + counts[shard] + ); + assert_eq!(tags[shard].len(), 128, "shard {shard}"); + } + assert_eq!(spread(u64::MAX, 3), 2); + assert_eq!(spread(0, 3), 0); +} + +/// A peer that sends several routes for a prefix can send more than fit a +/// sixteen-bit index. The best route was kept as one, and wrapped. +#[test] +fn a_row_can_hold_more_routes_than_a_sixteen_bit_index_counts() { + let store = AttrStore::new(); + let worse = store + .intern(&Route::via(&[1, 2]).attributes(), None) + .unwrap(); + let better = store.intern(&Route::via(&[1]).attributes(), None).unwrap(); + let info = external(1); + let peers = [Some(info)]; + let peer = PeerIndex(0); + let path = + |attrs: &Arc| Path::new(select::preference(attrs.key(), &info), peer, attrs); + let seqs = journal::Journals::new(journal::Limits::default()).appeared(prefix("10.0.0.0/8")); + + let mut row = Row::new(prefix("10.0.0.0/8"), path(&worse), 1, seqs); + let mut scratch = select::Scratch::default(); + let count = usize::from(u16::MAX) + 10; + for path_id in 2..=count as u32 { + row.push(path(&worse), path_id); + } + // Equal routes from one peer: the lowest identifier is the best. + assert!(!select::after_add(&mut row, &peers, &mut scratch)); + assert_eq!(row.best(), 0); + + row.push(path(&better), count as u32 + 1); + assert!(select::after_add(&mut row, &peers, &mut scratch)); + assert_eq!(row.best(), count); + assert_eq!(row.route(row.best()).path_id, count as u32 + 1); + + // With the better one gone, the choice among all the rest is the + // lowest identifier again. + assert!(select::after_remove(&mut row, count, &peers, &mut scratch)); + assert_eq!(row.route(row.best()).path_id, 1); +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index e1a5686..6b8b793 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,6 +1,11 @@ [package] name = "bgp-server" +authors.workspace = true edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true [dependencies] bgp-packet.workspace = true @@ -9,12 +14,19 @@ bgp-rib.workspace = true bytes.workspace = true clap.workspace = true futures.workspace = true -eyre.workspace = true prometheus.workspace = true +socket2.workspace = true tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true protobuf.workspace = true +thiserror.workspace = true [build-dependencies] protobuf-codegen.workspace = true + +[dev-dependencies] +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/server/build.rs b/crates/server/build.rs index f941313..9ddd8c7 100644 --- a/crates/server/build.rs +++ b/crates/server/build.rs @@ -2,8 +2,11 @@ use protobuf_codegen::Codegen; fn main() { Codegen::new() + // Parse the .proto in pure Rust so building needs no `protoc` binary + // on the machine (routers and minimal containers rarely have one). + .pure() .input("proto/config.proto") - .includes(&["proto"]) + .includes(["proto"]) .cargo_out_dir("protos") .run_from_script(); } diff --git a/crates/server/proto/config.proto b/crates/server/proto/config.proto index 3210367..8164668 100644 --- a/crates/server/proto/config.proto +++ b/crates/server/proto/config.proto @@ -17,6 +17,56 @@ message BgpConfig { // Peers configured on this server. repeated Peer peers = 4; + + // Routes this router originates and announces to every peer. + repeated OriginatedRoute originate = 5; + + // Where to serve the HTTP API, status page and metrics. The port is + // required. Unset disables the HTTP server. + TcpEndpoint http_endpoint = 6; + + // Initial delay between attempts to connect to a configured peer, in + // seconds. Doubles after each failure up to two minutes. Defaults to 5. + uint32 connect_retry_seconds = 7; + + // Connection collisions (RFC 4271 §6.8) against a session that is already + // Established are, by default, resolved by closing the new connection. + // When true they are resolved like any other collision: the connection + // initiated by the higher BGP Identifier survives, and a second connection + // opened by the same side replaces the first. This lets a peer that + // restarted take over from a session we have not yet noticed is dead, + // instead of being refused until the hold timer expires. + bool collision_detect_established_state = 8; + + // Connections are accepted only from the addresses of configured peers; + // from anywhere else they are closed before a byte is read. When true, + // any host that can reach a listening endpoint may open a session, as + // any AS it cares to name, and have its routes taken. That is for + // testing on a closed network and nothing else. + bool accept_unconfigured_peers = 9; +} + +// A route originated by this router. It is announced with an AS_PATH of the +// local AS and, on each session, that session's local address as next hop. +message OriginatedRoute { + // The prefix in CIDR notation, e.g. "192.0.2.0/24" or "2001:db8::/32". + string prefix = 1; + + // ORIGIN attribute: "IGP" (the default), "EGP" or "INCOMPLETE". + string origin = 2; + + // Extra copies of the local AS to prepend to the AS_PATH. At most 16. + uint32 as_path_prepend = 3; + + // MULTI_EXIT_DISC. Left out of the announcement when unset. + optional uint32 med = 4; + + // Standard communities (RFC 1997): "ASN:VALUE", or a well-known name such + // as "NO_EXPORT". + repeated string communities = 5; + + // Large communities (RFC 8092): "GLOBAL:LOCAL1:LOCAL2". + repeated string large_communities = 6; } // An IP address and port pair. @@ -41,4 +91,40 @@ message Peer { // Hold time to advertise in the OPEN message, in seconds (RFC 4271 §4.2). // Must be 0 or >= 3. Defaults to 90 when unset. uint32 hold_time_seconds = 4; + + // Source IP address for connections we open to this peer. It is also the + // next hop of the routes we send it. Required when the peer identifies us + // by address and the host has several, e.g. when both speakers run on the + // same machine. Must be the same address family as the endpoint. Unset + // lets the operating system choose. + string local_address = 5; + + // The address families to exchange with this peer. Each is offered to the + // peer in our OPEN, and is in use on the session only if the peer offers + // it too (RFC 4760). Defaults to all of them when empty. A session with + // no family in common is refused. + repeated AddressFamily address_families = 6; + + // ADD-PATH (RFC 7911): whether to take several routes for one prefix + // from this peer, to send it several, or both. It is in use, for each + // direction and address family, only if the peer agrees. Defaults to + // off: one route for each prefix, as in plain BGP. + AddPath add_path = 7; +} + +// The directions ADD-PATH can be offered for. +enum AddPath { + ADD_PATH_OFF = 0; + // Accept several routes for a prefix from the peer. + ADD_PATH_RECEIVE = 1; + // Send the peer several routes for a prefix. + ADD_PATH_SEND = 2; + ADD_PATH_BOTH = 3; +} + +// An address family routes can be exchanged for. +enum AddressFamily { + ADDRESS_FAMILY_UNSPECIFIED = 0; + IPV4_UNICAST = 1; + IPV6_UNICAST = 2; } diff --git a/crates/server/src/advertise.rs b/crates/server/src/advertise.rs new file mode 100644 index 0000000..8ccf790 --- /dev/null +++ b/crates/server/src/advertise.rs @@ -0,0 +1,814 @@ +//! Announces the routes this router originates to every established +//! session. +//! +//! Each session has an exporter of its own: a task that sends the session +//! the originated routes and then every change to them. An exporter waits +//! when its session's queue is full, which is what a peer that reads slowly +//! or not at all comes to, and it is the only thing that waits: the other +//! sessions have their own exporters and are not held up. +//! +//! An exporter remembers what it has announced. When it has waited so long +//! that it missed changes, it compares that with the routes as they now +//! are, announces those, and withdraws what is no longer among them. A peer +//! that was slow ends up with what every other peer has. +//! +//! The advertiser itself only starts an exporter when a session comes up +//! and stops it when the session goes. + +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; +use std::sync::Arc; + +use bgp_packet::message::{ + AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute, + LocalPrefPathAttribute, Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, + MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage, +}; +use bgp_primitives::constants::{AfiSafi, IpVersion}; +use bgp_primitives::ip_prefix::{IpPrefix, Nlri}; +use bgp_rib::table::DEFAULT_LOCAL_PREF; +use bgp_rib::{LocalRib, LocalRibEvent, LocalRoute}; +use tokio::sync::Notify; +use tokio::sync::broadcast::Receiver; +use tokio::sync::broadcast::error::RecvError; +use tokio::task::JoinHandle; +use tracing::{debug, info, warn}; + +use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry}; + +/// Starts the advertiser. It hears of every session that comes up after +/// this returns, so call it before anything that can bring one up. +pub fn spawn_advertiser( + local_rib: LocalRib, + registry: SessionRegistry, + local_asn: u32, +) -> JoinHandle<()> { + let advertiser = Advertiser { + session_events: registry.subscribe(), + local_rib, + registry, + local_asn, + exporters: HashMap::new(), + }; + tokio::spawn(advertiser.run()) +} + +/// An exporter, as the advertiser holds it. +struct Running { + session: SessionHandle, + /// Tells the exporter to send everything again. + resend: Arc, + task: JoinHandle<()>, +} + +impl Drop for Running { + fn drop(&mut self) { + self.task.abort(); + } +} + +struct Advertiser { + local_rib: LocalRib, + registry: SessionRegistry, + local_asn: u32, + session_events: Receiver, + exporters: HashMap, +} + +impl Advertiser { + /// Runs for as long as the server does. The server watches for this + /// task ending: without it nothing is announced. + async fn run(mut self) { + loop { + match self.session_events.recv().await { + Ok(SessionEvent::Up(session)) => self.start(session), + Ok(SessionEvent::Down(peer)) => { + self.exporters.remove(&peer); + } + Ok(SessionEvent::RefreshRequested(session)) => { + if let Some(exporter) = self.exporters.get(&session.peer) { + info!(peer = %session.peer, "Route refresh requested, re-sending local routes"); + exporter.resend.notify_one(); + } + } + Err(RecvError::Lagged(missed)) => { + warn!( + missed, + "Advertiser lagged on session events, checking every session" + ); + self.reconcile(); + } + // The registry is gone, and the server with it. + Err(RecvError::Closed) => return, + } + } + } + + /// Starts an exporter for a session that has come up. + fn start(&mut self, session: SessionHandle) { + let resend = Arc::new(Notify::new()); + let exporter = Exporter { + // Subscribed here, before the exporter first looks at the + // routes, so that no change between the two is missed. + changes: self.local_rib.subscribe(), + local_rib: self.local_rib.clone(), + session: session.clone(), + local_asn: self.local_asn, + resend: Arc::clone(&resend), + announced: HashSet::new(), + }; + let running = Running { + session: session.clone(), + resend, + task: tokio::spawn(exporter.run()), + }; + // A session with the same peer that this one replaces has gone, + // whether or not we have heard so yet; its exporter goes with it. + self.exporters.insert(session.peer, running); + } + + /// Makes the exporters match the sessions there are, after events were + /// missed: sessions may have come and gone unheard, and peers may have + /// asked for their routes again. + fn reconcile(&mut self) { + let sessions = self.registry.list(); + self.exporters.retain(|_, exporter| { + sessions + .iter() + .any(|session| session.is_same_session(&exporter.session)) + }); + for session in sessions { + match self.exporters.get(&session.peer) { + Some(exporter) => exporter.resend.notify_one(), + None => self.start(session), + } + } + } +} + +/// Keeps one session's peer up to date with the routes we originate. +struct Exporter { + local_rib: LocalRib, + changes: Receiver, + session: SessionHandle, + local_asn: u32, + resend: Arc, + /// The prefixes announced to the peer and not withdrawn since: our + /// Adj-RIB-Out for it (RFC 4271 §3.2), as far as it needs keeping. + announced: HashSet, +} + +/// Why an exporter has nothing more to do. +struct Finished; + +impl From for Finished { + fn from(_: SessionClosed) -> Self { + Self + } +} + +impl Exporter { + /// Runs until the session closes or the local RIB stops; the + /// advertiser stops it when it hears the session has gone. + async fn run(mut self) { + info!(peer = %self.session.peer, "Session up, sending local routes"); + let _ = self.export().await; + } + + async fn export(&mut self) -> Result<(), Finished> { + self.send_all().await?; + loop { + tokio::select! { + change = self.changes.recv() => match change { + Ok(LocalRibEvent::Announced(route)) => self.announce(&route).await?, + Ok(LocalRibEvent::Withdrawn { prefix }) => self.withdraw(prefix).await?, + Err(RecvError::Lagged(missed)) => { + info!( + peer = %self.session.peer, + missed, + "Peer fell behind on local routes, bringing it up to date" + ); + self.send_all().await?; + } + Err(RecvError::Closed) => return Err(Finished), + }, + () = self.resend.notified() => self.send_all().await?, + } + } + } + + /// Brings the peer to the routes as they now are: announces every one + /// of them, and withdraws what was announced and is no longer there. + /// + /// Changes made while this runs are in `changes`, to be handled after. + /// Some of those may be older than the routes read here; handling them + /// again, in order, ends in the same place. + async fn send_all(&mut self) -> Result<(), Finished> { + let routes = self.local_rib.snapshot().await.map_err(|_| Finished)?; + let current: HashSet = routes.iter().map(|route| route.prefix).collect(); + let gone: Vec = self.announced.difference(¤t).copied().collect(); + for prefix in gone { + self.withdraw(prefix).await?; + } + for route in &routes { + self.announce(route).await?; + } + Ok(()) + } + + async fn announce(&mut self, route: &LocalRoute) -> Result<(), Finished> { + let session = &self.session; + if !session.families.carries(&route.prefix) { + debug!( + peer = %session.peer, + prefix = %route.prefix, + "Skipping route: its address family was not negotiated" + ); + return Ok(()); + } + let towards = Towards { + local_asn: self.local_asn, + next_hop: session.local_addr.ip(), + four_octet_asn: session.four_octet_asn, + ibgp: session.ibgp, + }; + let Some(update) = build_announce(route, &towards) else { + debug!( + peer = %session.peer, + prefix = %route.prefix, + "Skipping route: address family does not match session transport" + ); + return Ok(()); + }; + debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route"); + session.send(Message::Update(update)).await?; + self.announced.insert(route.prefix); + Ok(()) + } + + async fn withdraw(&mut self, prefix: IpPrefix) -> Result<(), Finished> { + if !self.announced.remove(&prefix) { + // The peer was never told of it. + return Ok(()); + } + debug!(peer = %self.session.peer, %prefix, "Withdrawing local route"); + self.session + .send(Message::Update(build_withdraw(&prefix))) + .await?; + Ok(()) + } +} + +/// What about a session decides how a route is announced on it. +#[derive(Debug, Clone, Copy)] +pub struct Towards { + pub local_asn: u32, + /// Our address on the session, which is the routes' next hop. + pub next_hop: IpAddr, + /// Whether the session negotiated four-octet AS numbers. If it did + /// not, and an AS number in the path needs four octets, the path is + /// also sent as AS4_PATH so that it survives the two-octet speaker + /// (RFC 6793 §4.2.2). + pub four_octet_asn: bool, + /// Whether the peer is in our own AS. + pub ibgp: bool, +} + +/// Builds the UPDATE announcing `route` on a session. +/// +/// Returns `None` when the prefix family does not match the next-hop family: +/// carrying IPv4 NLRI over an IPv6 next hop (RFC 8950) or vice versa is not +/// supported yet. +pub fn build_announce(route: &LocalRoute, towards: &Towards) -> Option { + let attrs = &route.attrs; + + // Our AS number goes on the path as a route leaves the AS, and not + // before (RFC 4271 §5.1.2): a route we originate has an empty path + // inside it. A peer in our AS that found our AS number on the path + // would take the route for one that had looped. + let as_path = AsPathAttribute { + segments: if towards.ibgp { + vec![] + } else { + vec![AsPathSegment { + ordered: true, + path: vec![towards.local_asn; 1 + usize::from(attrs.prepend)], + }] + }, + }; + let as4_path = if towards.four_octet_asn { + None + } else { + as_path.as4_path() + }; + + // Path attributes in ascending type-code order, as most implementations + // expect them. + let mut path_attributes = vec![ + PathAttribute::Origin(attrs.origin), + PathAttribute::AsPath(as_path), + ]; + // IPv4 routes go in the body of the UPDATE with their next hop in + // NEXT_HOP; IPv6 routes go in MP_REACH_NLRI, which carries its own. + let mut announced_routes = vec![]; + let mp_reach = match (route.prefix.address_family(), towards.next_hop) { + (IpVersion::Ipv4, IpAddr::V4(next_hop)) => { + path_attributes.push(PathAttribute::NextHop(NextHopPathAttribute(next_hop))); + announced_routes.push(route.prefix.into()); + None + } + (IpVersion::Ipv6, IpAddr::V6(next_hop)) => { + Some(PathAttribute::MpReachNlri(MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6(next_hop), + prefixes: vec![route.prefix.into()], + })) + } + _ => return None, + }; + if let Some(med) = attrs.med { + path_attributes.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute( + med, + ))); + } + // LOCAL_PREF is sent to peers in our AS and to no others (RFC 4271 + // §5.1.5). + if towards.ibgp { + path_attributes.push(PathAttribute::LocalPref(LocalPrefPathAttribute( + DEFAULT_LOCAL_PREF, + ))); + } + if !attrs.communities.is_empty() { + path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute( + attrs.communities.clone(), + ))); + } + path_attributes.extend(mp_reach); + path_attributes.extend(as4_path); + if !attrs.large_communities.is_empty() { + path_attributes.push(PathAttribute::LargeCommunities( + LargeCommunitiesPathAttribute { + communities: attrs.large_communities.clone(), + }, + )); + } + + Some(UpdateMessage { + withdrawn_routes: vec![], + path_attributes, + announced_routes, + }) +} + +/// Builds the UPDATE withdrawing `prefix`. +pub fn build_withdraw(prefix: &IpPrefix) -> UpdateMessage { + match prefix.address_family() { + IpVersion::Ipv4 => UpdateMessage { + withdrawn_routes: vec![Nlri::from(*prefix)], + path_attributes: vec![], + announced_routes: vec![], + }, + IpVersion::Ipv6 => UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + prefixes: vec![Nlri::from(*prefix)], + })], + announced_routes: vec![], + }, + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use bgp_packet::message::{LargeCommunity, OriginPathAttribute}; + use bgp_primitives::parser::ParserContext; + use bgp_rib::LocalRouteAttrs; + use bytes::BytesMut; + + use super::*; + + fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() + } + + /// An external peer. + fn towards(local_asn: u32, next_hop: IpAddr, four_octet_asn: bool) -> Towards { + Towards { + local_asn, + next_hop, + four_octet_asn, + ibgp: false, + } + } + + #[test] + fn ipv4_announce_has_expected_attributes_and_roundtrips() { + let route = LocalRoute::new( + prefix("10.100.1.0/24"), + LocalRouteAttrs { + origin: OriginPathAttribute::IGP, + prepend: 2, + med: Some(50), + communities: vec![(65000, 100), (65535, 65281)], + large_communities: vec![LargeCommunity { + global_admin: 65000, + data_1: 1, + data_2: 2, + }], + }, + ); + let update = build_announce( + &route, + &towards(65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), true), + ) + .unwrap(); + + assert_eq!(update.announced_routes, vec![prefix("10.100.1.0/24")]); + assert!(update.withdrawn_routes.is_empty()); + assert_eq!( + update.path_attributes, + vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + AsPathAttribute::from_asns(vec![65000, 65000, 65000]), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(50)), + PathAttribute::Communities(CommunitiesPathAttribute(vec![ + (65000, 100), + (65535, 65281) + ])), + PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute { + communities: vec![LargeCommunity { + global_admin: 65000, + data_1: 1, + data_2: 2 + }], + }), + ] + ); + + // Serialise and parse back to make sure the wire encoding is sound. + let ctx = ParserContext { + four_octet_asn: true, + ibgp: false, + add_path: Vec::new(), + }; + let msg = Message::Update(update.clone()); + let mut buf = BytesMut::new(); + msg.to_wire(&ctx, &mut buf).unwrap(); + let Message::Update(parsed) = Message::decode(&ctx, &buf).unwrap() else { + panic!("expected UPDATE") + }; + assert_eq!(parsed, update); + } + + #[test] + fn ipv6_announce_uses_mp_reach() { + let route = LocalRoute::new(prefix("2001:db8:1::/48"), LocalRouteAttrs::default()); + let nh = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + let update = build_announce(&route, &towards(65000, IpAddr::V6(nh), true)).unwrap(); + + assert!(update.announced_routes.is_empty()); + assert!( + !update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::NextHop(_))) + ); + assert!(update.path_attributes.iter().any(|a| matches!( + a, + PathAttribute::MpReachNlri(MpReachNlriPathAttribute { next_hop: NlriNextHop::Ipv6(h), prefixes, .. }) + if *h == nh && *prefixes == vec![prefix("2001:db8:1::/48")] + ))); + } + + /// A two-octet session cannot carry a four-octet AS number in AS_PATH: + /// it goes out as AS_TRANS there, and in full in AS4_PATH. + #[test] + fn a_wide_as_number_reaches_a_two_octet_peer_in_as4_path() { + let route = LocalRoute::new( + prefix("10.100.1.0/24"), + LocalRouteAttrs { + prepend: 1, + ..LocalRouteAttrs::default() + }, + ); + let next_hop = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)); + let path = |update: &UpdateMessage, as4: bool| { + update.path_attributes.iter().find_map(|a| match a { + PathAttribute::As4Path(p) if as4 => Some(p.segments[0].path.clone()), + PathAttribute::AsPath(p) if !as4 => Some(p.segments[0].path.clone()), + _ => None, + }) + }; + + let update = build_announce(&route, &towards(4_200_000_001, next_hop, false)).unwrap(); + assert_eq!(path(&update, true), Some(vec![4_200_000_001; 2])); + + // What the peer reads from the wire. + let ctx = ParserContext { + four_octet_asn: false, + ibgp: false, + add_path: Vec::new(), + }; + let mut buf = BytesMut::new(); + Message::Update(update).to_wire(&ctx, &mut buf).unwrap(); + let Message::Update(seen) = Message::decode(&ctx, &buf).unwrap() else { + panic!("expected UPDATE") + }; + assert_eq!(path(&seen, false), Some(vec![23456; 2]), "AS_TRANS"); + assert_eq!(path(&seen, true), Some(vec![4_200_000_001; 2])); + + // No AS4_PATH when it would say nothing new: the peer handles + // four-octet numbers, or ours fits in two. + let update = build_announce(&route, &towards(4_200_000_001, next_hop, true)).unwrap(); + assert_eq!(path(&update, true), None); + let update = build_announce(&route, &towards(65000, next_hop, false)).unwrap(); + assert_eq!(path(&update, true), None); + assert_eq!(path(&update, false), Some(vec![65000; 2])); + } + + #[test] + fn family_mismatch_is_skipped() { + let v6 = LocalRoute::new(prefix("2001:db8::/32"), LocalRouteAttrs::default()); + assert!( + build_announce(&v6, &towards(65000, IpAddr::V4(Ipv4Addr::LOCALHOST), true)).is_none() + ); + let v4 = LocalRoute::new(prefix("10.0.0.0/8"), LocalRouteAttrs::default()); + assert!( + build_announce(&v4, &towards(65000, IpAddr::V6(Ipv6Addr::LOCALHOST), true)).is_none() + ); + } + + #[test] + fn withdraw_by_family() { + let v4 = build_withdraw(&prefix("10.0.0.0/8")); + assert_eq!(v4.withdrawn_routes, vec![prefix("10.0.0.0/8")]); + assert!(v4.path_attributes.is_empty()); + + let v6 = build_withdraw(&prefix("2001:db8::/32")); + assert!(v6.withdrawn_routes.is_empty()); + assert!( + matches!(&v6.path_attributes[..], [PathAttribute::MpUnreachNlri(m)] if m.prefixes == vec![prefix("2001:db8::/32")]) + ); + } + + /// Inside our own AS a route we originate has an empty AS path and a + /// LOCAL_PREF. With our AS number on the path, a peer in our AS takes + /// the route for a loop; without LOCAL_PREF it treats the route as + /// withdrawn, as our own decoder does. + #[test] + fn a_route_announced_inside_our_as_has_an_empty_path_and_a_local_pref() { + let route = LocalRoute::new( + prefix("10.100.1.0/24"), + LocalRouteAttrs { + prepend: 3, + ..LocalRouteAttrs::default() + }, + ); + let internal = Towards { + ibgp: true, + ..towards(65000, IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), true) + }; + let update = build_announce(&route, &internal).unwrap(); + assert_eq!( + update.path_attributes, + vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + PathAttribute::AsPath(AsPathAttribute { segments: vec![] }), + PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))), + PathAttribute::LocalPref(LocalPrefPathAttribute(100)), + ] + ); + + // An internal peer accepts it as it stands. + let ctx = ParserContext { + four_octet_asn: true, + ibgp: true, + add_path: Vec::new(), + }; + let mut buf = BytesMut::new(); + Message::Update(update.clone()) + .to_wire(&ctx, &mut buf) + .unwrap(); + let decoded = Message::decode_with_report(&ctx, &buf).unwrap(); + assert!( + decoded.update_errors.is_empty(), + "{:?}", + decoded.update_errors + ); + assert_eq!(decoded.message, Message::Update(update)); + + // To an external peer the same route has our AS on the path, as + // many times as configured, and no LOCAL_PREF. + let external = Towards { + ibgp: false, + ..internal + }; + let update = build_announce(&route, &external).unwrap(); + assert_eq!( + update.path_attributes[1], + AsPathAttribute::from_asns(vec![65000; 4]) + ); + assert!( + !update + .path_attributes + .iter() + .any(|a| matches!(a, PathAttribute::LocalPref(_))) + ); + } + + // -- Exporters -- + + use std::time::Duration; + + use bgp_rib::table::{PeerInfo, RibTable}; + use tokio::sync::mpsc; + + use crate::collision::Direction; + use crate::family::FamilySet; + use crate::fsm::Negotiated; + use crate::session::SessionGuard; + + /// A registry and local RIB with an advertiser running, and a table to + /// take peer numbers from. + struct Fixture { + registry: SessionRegistry, + local_rib: LocalRib, + table: RibTable, + } + + /// The far end of a session's queue: what the session's task would + /// write to the peer. + struct Queue { + outbound: mpsc::Receiver, + /// What the peer holds, going by what it has been sent. + routes: HashSet, + _registration: SessionGuard, + } + + impl Fixture { + fn new() -> Self { + let registry = SessionRegistry::new(); + let local_rib = LocalRib::spawn(); + spawn_advertiser(local_rib.clone(), registry.clone(), 65000); + Self { + registry, + local_rib, + table: RibTable::new(1), + } + } + + /// A session with peer number `n`, whose queue holds `capacity` + /// messages. + fn session(&self, n: u8, capacity: usize) -> Queue { + let address = IpAddr::V4(Ipv4Addr::new(192, 0, 2, n)); + let negotiated = Negotiated { + peer_asn: 65000 + u32::from(n), + ibgp: false, + peer_identifier: Ipv4Addr::new(10, 0, 0, n), + hold_time_secs: 90, + four_octet_asn: true, + families: FamilySet::supported(), + add_path_receive: FamilySet::default(), + add_path_send: FamilySet::default(), + }; + let rib_peer = self + .table + .add_peer(PeerInfo { + asn: negotiated.peer_asn, + router_id: negotiated.peer_identifier, + address, + ibgp: false, + }) + .unwrap(); + let (tx, outbound) = mpsc::channel(capacity); + let handle = SessionHandle::new( + PeerId { + addr: (address, 179).into(), + asn: negotiated.peer_asn, + }, + None, + Direction::Inbound, + (Ipv4Addr::new(192, 0, 2, 100), 179).into(), + &negotiated, + rib_peer, + tx, + ); + Queue { + outbound, + routes: HashSet::new(), + _registration: self.registry.register(handle), + } + } + + async fn announce(&self, n: u32) { + let route = LocalRoute::new(nth_prefix(n), LocalRouteAttrs::default()); + self.local_rib.announce(route).await.unwrap(); + } + + async fn routes(&self) -> HashSet { + let routes = self.local_rib.snapshot().await.unwrap(); + routes.iter().map(|route| route.prefix).collect() + } + } + + fn nth_prefix(n: u32) -> IpPrefix { + let bytes = (0x0a00_0000 + (n << 8)).to_be_bytes(); + IpPrefix::new(IpVersion::Ipv4, &bytes[..3], 24).unwrap() + } + + impl Queue { + /// Reads what has been queued for the peer until it holds exactly + /// `expected`, as a peer would that takes every message in turn. + async fn read_until_it_has(&mut self, expected: &HashSet) { + let reading = async { + while self.routes != *expected { + let Some(Message::Update(update)) = self.outbound.recv().await else { + panic!("expected an UPDATE"); + }; + for nlri in &update.withdrawn_routes { + self.routes.remove(&nlri.prefix); + } + for nlri in &update.announced_routes { + self.routes.insert(nlri.prefix); + } + } + }; + if tokio::time::timeout(Duration::from_secs(10), reading) + .await + .is_err() + { + panic!( + "the peer has {} routes, {} of them right, and should have {}", + self.routes.len(), + self.routes.intersection(expected).count(), + expected.len() + ); + } + } + } + + /// One peer that takes nothing must not keep the others from being + /// sent their routes. There used to be one task announcing to every + /// session in turn, and it waited for whichever queue was full. + #[tokio::test] + async fn a_peer_that_reads_nothing_holds_up_nobody_else() { + let fixture = Fixture::new(); + let _stuck = fixture.session(1, 4); + let mut reading = fixture.session(2, 4); + + for n in 0..500 { + fixture.announce(n).await; + } + reading.read_until_it_has(&fixture.routes().await).await; + assert_eq!(reading.routes.len(), 500); + } + + /// A peer that reads so slowly that changes pass it by is brought up + /// to date when it reads again: with the routes there now are, and + /// without the ones that went while it was not looking. Those used to + /// stay announced to it for good. + #[tokio::test] + async fn a_peer_that_fell_behind_ends_up_with_the_routes_there_are() { + let fixture = Fixture::new(); + let mut slow = fixture.session(1, 4); + for n in 0..10 { + fixture.announce(n).await; + } + slow.read_until_it_has(&fixture.routes().await).await; + + // Far more changes than are kept for a subscriber that is not + // reading, among them the withdrawal of what the peer has. + for n in 0..10 { + fixture.local_rib.withdraw(nth_prefix(n)).await.unwrap(); + } + for n in 10..3000 { + fixture.announce(n).await; + } + for n in 2000..3000 { + fixture.local_rib.withdraw(nth_prefix(n)).await.unwrap(); + } + + let routes = fixture.routes().await; + assert_eq!(routes.len(), 1990); + slow.read_until_it_has(&routes).await; + } + + /// When a peer's session is replaced by a new one, the new session is + /// sent the routes from the beginning. + #[tokio::test] + async fn a_new_session_with_the_same_peer_starts_afresh() { + let fixture = Fixture::new(); + fixture.announce(1).await; + let routes = fixture.routes().await; + + let mut first = fixture.session(1, 4); + first.read_until_it_has(&routes).await; + drop(first); + let mut second = fixture.session(1, 4); + second.read_until_it_has(&routes).await; + } +} diff --git a/crates/server/src/collision.rs b/crates/server/src/collision.rs new file mode 100644 index 0000000..f067391 --- /dev/null +++ b/crates/server/src/collision.rs @@ -0,0 +1,304 @@ +//! Connection collision detection (RFC 4271 §6.8). +//! +//! Two speakers that dial each other can end up with two TCP connections. +//! Exactly one must survive and both ends must pick the same one: the +//! connection *initiated by the speaker with the higher BGP Identifier*. +//! The other is closed with Cease / Connection Collision Resolution. +//! +//! This cannot be a check local to one connection: the rival connection is +//! usually still mid-handshake (so it is not in the session registry yet), +//! and sometimes it is the rival, running in another task, that must close. +//! Hence one small shared map, holding the single connection currently +//! preferred for each peer and a way to tell it to stop. +//! +//! A peer here is an address together with the BGP Identifier it announced. +//! The identifier alone would not do: a router commonly has one session +//! over IPv4 and another over IPv6 with the same neighbour, which are two +//! sessions and not a collision; and it would let any host that can reach +//! us displace a peer's connection by claiming the peer's identifier. +//! +//! A collision with an *Established* connection closes the new one, unless +//! `detect_established` is set (the RFC's optional +//! `CollisionDetectEstablishedState`), which lets a restarted peer replace a +//! session we have not yet noticed is dead. If both connections were opened +//! by the same side the newer wins: the initiator has abandoned the older. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use tokio::sync::oneshot; + +use crate::metrics::COLLISIONS; +use crate::sync::IgnorePoison; + +/// Which side opened the TCP connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + /// The peer connected to us. + Inbound, + /// We connected to the peer. + Outbound, +} + +impl Direction { + pub fn as_str(&self) -> &'static str { + match self { + Direction::Inbound => "inbound", + Direction::Outbound => "outbound", + } + } +} + +struct Entry { + id: u64, + direction: Direction, + established: bool, + kill: Option>, +} + +/// True if the new connection is the one to close. +fn new_loses( + local_id: Ipv4Addr, + remote_id: Ipv4Addr, + existing: &Entry, + new: Direction, + detect_established: bool, +) -> bool { + if existing.established && !detect_established { + return true; + } + if existing.direction == new { + return false; + } + // Identifiers compare as unsigned 32-bit integers. + let keep = if u32::from(local_id) > u32::from(remote_id) { + Direction::Outbound + } else { + Direction::Inbound + }; + new != keep +} + +/// Who a connection is with: the address at the other end, and the BGP +/// Identifier announced from it. +type Peer = (IpAddr, Ipv4Addr); + +/// Shared by every connection of one server. +#[derive(Clone)] +pub struct CollisionTable { + entries: Arc>>, + next_id: Arc, + detect_established: bool, +} + +impl CollisionTable { + pub fn new(detect_established: bool) -> Self { + Self { + entries: Default::default(), + next_id: Default::default(), + detect_established, + } + } + + /// Call when a connection receives the peer's OPEN. `None` means this + /// connection lost and must be closed with Cease / Connection Collision + /// Resolution. Otherwise any rival has been told to close and this + /// connection holds the slot until the guard drops. + pub fn admit( + &self, + local_id: Ipv4Addr, + remote_addr: IpAddr, + remote_id: Ipv4Addr, + direction: Direction, + ) -> Option { + let peer = (remote_addr, remote_id); + let mut entries = self.entries.locked(); + if let Some(existing) = entries.get_mut(&peer) { + if new_loses( + local_id, + remote_id, + existing, + direction, + self.detect_established, + ) { + COLLISIONS.with_label_values(&["new"]).inc(); + return None; + } + if let Some(kill) = existing.kill.take() { + COLLISIONS.with_label_values(&["existing"]).inc(); + let _ = kill.send(()); + } + } + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (kill, killed) = oneshot::channel(); + entries.insert( + peer, + Entry { + id, + direction, + established: false, + kill: Some(kill), + }, + ); + Some(ConnectionGuard { + table: self.clone(), + peer, + id, + killed, + }) + } +} + +/// Holds a connection's slot in the table; releases it on drop. +pub struct ConnectionGuard { + table: CollisionTable, + peer: Peer, + id: u64, + killed: oneshot::Receiver<()>, +} + +impl ConnectionGuard { + pub fn set_established(&self) { + let mut entries = self.table.entries.locked(); + if let Some(e) = entries.get_mut(&self.peer).filter(|e| e.id == self.id) { + e.established = true; + } + } + + /// Completes when a later connection has won against this one; the + /// owner must then send Cease / Connection Collision Resolution and + /// close. Cancel-safe. + pub async fn killed(&mut self) { + // The sending half is dropped only when it has been used: the entry + // that holds it is removed by this guard alone, or replaced by a + // connection that signals first. Either way, the wait is over. + let _ = (&mut self.killed).await; + } +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { + // A connection that lost has already been replaced in the map; only + // remove the slot if it is still ours. + let mut entries = self.table.entries.locked(); + if entries.get(&self.peer).is_some_and(|e| e.id == self.id) { + entries.remove(&self.peer); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use Direction::*; + + const LOW: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const HIGH: Ipv4Addr = Ipv4Addr::new(192, 0, 2, 1); + + fn loses( + local: Ipv4Addr, + remote: Ipv4Addr, + existing: Direction, + established: bool, + new: Direction, + detect: bool, + ) -> bool { + let e = Entry { + id: 0, + direction: existing, + established, + kill: None, + }; + new_loses(local, remote, &e, new, detect) + } + + #[test] + fn higher_identifier_keeps_the_connection_it_initiated() { + // We are HIGH: our outbound connection survives. + assert!(loses(HIGH, LOW, Outbound, false, Inbound, false)); + assert!(!loses(HIGH, LOW, Inbound, false, Outbound, false)); + // We are LOW: the peer's connection (inbound to us) survives. + assert!(!loses(LOW, HIGH, Outbound, false, Inbound, false)); + assert!(loses(LOW, HIGH, Inbound, false, Outbound, false)); + } + + #[test] + fn both_ends_agree() { + // A and B dial each other. Each end holds its own dial as the + // existing (Outbound) connection and sees the other's dial arrive as + // new (Inbound). Exactly one of the two dials may be closed, so the + // ends must give opposite answers to "close the other side's dial?". + for (a, b) in [(LOW, HIGH), (HIGH, LOW)] { + let a_closes_dial_from_b = loses(a, b, Outbound, false, Inbound, false); + let b_closes_dial_from_a = loses(b, a, Outbound, false, Inbound, false); + assert_ne!( + a_closes_dial_from_b, b_closes_dial_from_a, + "ends disagree for a={a} b={b}" + ); + } + } + + #[test] + fn established_wins_unless_enabled() { + for new in [Inbound, Outbound] { + for existing in [Inbound, Outbound] { + assert!(loses(LOW, HIGH, existing, true, new, false)); + assert!(loses(HIGH, LOW, existing, true, new, false)); + } + } + assert!(!loses(LOW, HIGH, Outbound, true, Inbound, true)); + assert!(loses(HIGH, LOW, Outbound, true, Inbound, true)); + } + + #[test] + fn same_direction_newer_wins() { + assert!(!loses(LOW, HIGH, Inbound, false, Inbound, false)); + assert!(!loses(HIGH, LOW, Inbound, false, Inbound, false)); + assert!(loses(LOW, HIGH, Inbound, true, Inbound, false)); + assert!(!loses(LOW, HIGH, Inbound, true, Inbound, true)); + } + + #[tokio::test] + async fn table_signals_loser_and_releases_slots() { + let table = CollisionTable::new(false); + let addr: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into(); + // We are LOW: outbound first, then the peer's inbound wins. + let mut out = table.admit(LOW, addr, HIGH, Outbound).unwrap(); + let inb = table.admit(LOW, addr, HIGH, Inbound).unwrap(); + assert!(out.killed.try_recv().is_ok()); + // The loser's guard dropping must not free the winner's slot. + drop(out); + inb.set_established(); + assert!(table.admit(LOW, addr, HIGH, Outbound).is_none()); + + // A different identifier never collides. + assert!( + table + .admit(LOW, addr, Ipv4Addr::new(192, 0, 2, 2), Inbound) + .is_some() + ); + + // Once the survivor goes away the identifier is free again. + drop(inb); + assert!(table.admit(LOW, addr, HIGH, Outbound).is_some()); + } + + /// A neighbour reached over IPv4 and over IPv6 announces the same + /// identifier on both sessions. They are two sessions, and neither + /// closes the other. Nor can a connection from elsewhere that claims a + /// peer's identifier take the place of the peer's own. + #[tokio::test] + async fn the_same_identifier_from_another_address_is_another_peer() { + let table = CollisionTable::new(true); + let v4: IpAddr = Ipv4Addr::new(192, 0, 2, 1).into(); + let v6: IpAddr = "2001:db8::1".parse().unwrap(); + + let mut over_v4 = table.admit(LOW, v4, HIGH, Outbound).unwrap(); + over_v4.set_established(); + let over_v6 = table.admit(LOW, v6, HIGH, Inbound); + assert!(over_v6.is_some()); + assert!(over_v4.killed.try_recv().is_err(), "the first is untouched"); + } +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 317ea7a..e564cae 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -1,12 +1,22 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::str::FromStr; +use std::time::Duration; -use eyre::{Result, bail, eyre}; +use bgp_packet::community; +use bgp_packet::message::OriginPathAttribute; +use bgp_packet::open::AddPathSendRecv; +use bgp_primitives::constants::AfiSafi; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::{LocalRoute, LocalRouteAttrs}; -use crate::proto::config::BgpConfig; +use crate::error::{ConfigError, OriginateProblem, PeerProblem}; +use crate::family::FamilySet; +use crate::proto::config::{self as proto, BgpConfig, OriginatedRoute, Peer}; const DEFAULT_BGP_PORT: u16 = 179; const DEFAULT_HOLD_TIME_SECS: u16 = 90; +const DEFAULT_CONNECT_RETRY: Duration = Duration::from_secs(5); +const MAX_AS_PATH_PREPEND: u32 = 16; /// Validated, Rust-native server configuration derived from the proto config. pub struct Config { @@ -16,79 +26,477 @@ pub struct Config { pub hold_time_secs: u16, pub listen_endpoints: Vec, pub peers: Vec, + /// Initial delay between attempts to connect to a configured peer; + /// doubles on each failure. + pub connect_retry: Duration, + /// Resolve collisions against Established sessions by BGP Identifier + /// instead of always closing the new connection (RFC 4271 §8.1.1 + /// `CollisionDetectEstablishedState`). + pub collision_detect_established_state: bool, + /// Take connections from addresses that are not those of configured + /// peers. Off unless asked for: with it, any host that can reach us can + /// open a session and announce routes. + pub accept_unconfigured_peers: bool, + /// Routes this router originates. + pub originate: Vec, + /// Address for the HTTP API; `None` disables it. + pub http_endpoint: Option, } +#[derive(Debug, Clone)] pub struct PeerConfig { pub name: String, pub endpoint: SocketAddr, pub remote_asn: u32, pub hold_time_secs: u16, + /// Source address for connections we open to this peer; `None` lets the + /// operating system choose. + pub local_address: Option, + /// The address families offered to this peer. + pub families: FamilySet, + /// The directions ADD-PATH (RFC 7911) is offered to this peer for, on + /// every one of `families`. `None` offers it for neither. + pub add_path: Option, } impl Config { - pub fn from_proto(proto: BgpConfig) -> Result { + /// The configured peer at `address`, if there is one. + pub fn peer_at(&self, address: IpAddr) -> Option<&PeerConfig> { + self.peers.iter().find(|peer| peer.endpoint.ip() == address) + } + + pub fn from_proto(proto: BgpConfig) -> Result { + // Peers refuse an identifier of 0.0.0.0, as we do theirs. let router_id = Ipv4Addr::from_str(&proto.router_id) - .map_err(|_| eyre!("Invalid router_id {:?}: must be a dotted-quad IPv4 address", proto.router_id))?; + .ok() + .filter(|id| !id.is_unspecified()) + .ok_or_else(|| ConfigError::RouterId(proto.router_id.clone()))?; if proto.local_asn == 0 { - bail!("local_asn must be non-zero"); + return Err(ConfigError::LocalAsn); } let listen_endpoints = if proto.listen_endpoints.is_empty() { vec![ SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), DEFAULT_BGP_PORT), - SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), DEFAULT_BGP_PORT), + SocketAddr::new( + IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + DEFAULT_BGP_PORT, + ), ] } else { - proto.listen_endpoints - .into_iter() + proto + .listen_endpoints + .iter() .map(|ep| parse_endpoint(&ep.address, ep.port)) - .collect::>()? + .collect::>()? }; - let peers = proto.peers - .into_iter() - .map(|p| { - let ep = p.endpoint.as_ref() - .ok_or_else(|| eyre!("Peer {:?} is missing endpoint", p.name))?; - let endpoint = parse_endpoint(&ep.address, ep.port)?; + let mut peers: Vec = Vec::with_capacity(proto.peers.len()); + for peer in proto.peers { + let name = peer.name.clone(); + let rejected = |problem| ConfigError::Peer { + peer: name.clone(), + problem, + }; + let peer = parse_peer(peer).map_err(rejected)?; + // A peer is told from the others by its name in what the + // dialer reports, and by its address when it connects to us. + if peers.iter().any(|earlier| earlier.name == peer.name) { + return Err(rejected(PeerProblem::DuplicateName)); + } + if peers + .iter() + .any(|earlier| earlier.endpoint.ip() == peer.endpoint.ip()) + { + return Err(rejected(PeerProblem::DuplicateAddress)); + } + peers.push(peer); + } - if p.remote_asn == 0 { - bail!("Peer {:?} remote_asn must be non-zero", p.name); - } + let mut originate: Vec = Vec::with_capacity(proto.originate.len()); + for entry in &proto.originate { + let rejected = |problem| ConfigError::Originate { + prefix: entry.prefix.clone(), + problem, + }; + let route = parse_originated_route(entry).map_err(rejected)?; + if originate + .iter() + .any(|earlier| earlier.prefix == route.prefix) + { + return Err(rejected(OriginateProblem::Duplicate)); + } + originate.push(route); + } - let hold_time_secs = if p.hold_time_seconds == 0 { - DEFAULT_HOLD_TIME_SECS - } else if p.hold_time_seconds < 3 { - bail!("Peer {:?} hold_time_seconds must be 0 or >= 3", p.name); - } else { - p.hold_time_seconds as u16 - }; + let http_endpoint = match proto.http_endpoint.into_option() { + Some(ep) if ep.port == 0 => return Err(ConfigError::HttpPort), + Some(ep) => Some(parse_endpoint(&ep.address, ep.port)?), + None => None, + }; - Ok(PeerConfig { - name: p.name, - endpoint, - remote_asn: p.remote_asn, - hold_time_secs, - }) - }) - .collect::>()?; + let connect_retry = if proto.connect_retry_seconds == 0 { + DEFAULT_CONNECT_RETRY + } else { + Duration::from_secs(proto.connect_retry_seconds as u64) + }; - Ok(Self { router_id, local_asn: proto.local_asn, hold_time_secs: DEFAULT_HOLD_TIME_SECS, listen_endpoints, peers }) + Ok(Self { + router_id, + local_asn: proto.local_asn, + hold_time_secs: DEFAULT_HOLD_TIME_SECS, + listen_endpoints, + peers, + connect_retry, + collision_detect_established_state: proto.collision_detect_established_state, + accept_unconfigured_peers: proto.accept_unconfigured_peers, + originate, + http_endpoint, + }) } } -fn parse_endpoint(address: &str, port: u32) -> Result { - let port = if port == 0 { - DEFAULT_BGP_PORT - } else if port > u16::MAX as u32 { - bail!("Port {port} is out of range"); - } else { - port as u16 +fn parse_peer(peer: Peer) -> Result { + let ep = peer.endpoint.as_ref().ok_or(PeerProblem::MissingEndpoint)?; + let endpoint = + parse_endpoint(&ep.address, ep.port).map_err(|e| PeerProblem::Endpoint(Box::new(e)))?; + + if peer.remote_asn == 0 { + return Err(PeerProblem::RemoteAsn); + } + + let hold_time_secs = match peer.hold_time_seconds { + 0 => DEFAULT_HOLD_TIME_SECS, + // RFC 4271 §4.2: zero, or at least three seconds. + 1 | 2 => return Err(PeerProblem::HoldTime), + secs => u16::try_from(secs).map_err(|_| PeerProblem::HoldTime)?, }; - let ip = IpAddr::from_str(address) - .map_err(|_| eyre!("Invalid IP address {address:?}"))?; + let local_address = if peer.local_address.is_empty() { + None + } else { + let ip = IpAddr::from_str(&peer.local_address) + .map_err(|_| PeerProblem::LocalAddress(peer.local_address.clone()))?; + if ip.is_ipv4() != endpoint.is_ipv4() { + return Err(PeerProblem::AddressFamilyMismatch); + } + Some(ip) + }; + let families = if peer.address_families.is_empty() { + FamilySet::supported() + } else { + peer.address_families + .iter() + .map(|family| match family.enum_value() { + Ok(proto::AddressFamily::IPV4_UNICAST) => Ok(AfiSafi::IPV4_UNICAST), + Ok(proto::AddressFamily::IPV6_UNICAST) => Ok(AfiSafi::IPV6_UNICAST), + Ok(proto::AddressFamily::ADDRESS_FAMILY_UNSPECIFIED) | Err(_) => { + Err(PeerProblem::AddressFamily) + } + }) + .collect::>()? + }; + + let add_path = match peer.add_path.enum_value() { + Ok(proto::AddPath::ADD_PATH_OFF) => None, + Ok(proto::AddPath::ADD_PATH_RECEIVE) => Some(AddPathSendRecv::Receive), + Ok(proto::AddPath::ADD_PATH_SEND) => Some(AddPathSendRecv::Send), + Ok(proto::AddPath::ADD_PATH_BOTH) => Some(AddPathSendRecv::Both), + Err(_) => return Err(PeerProblem::AddPath), + }; + + Ok(PeerConfig { + // A peer without a name goes by its address. + name: if peer.name.is_empty() { + endpoint.ip().to_string() + } else { + peer.name + }, + endpoint, + remote_asn: peer.remote_asn, + hold_time_secs, + local_address, + families, + add_path, + }) +} + +fn parse_endpoint(address: &str, port: u32) -> Result { + let port = match port { + 0 => DEFAULT_BGP_PORT, + port => u16::try_from(port).map_err(|_| ConfigError::Port(port))?, + }; + let ip = IpAddr::from_str(address).map_err(|_| ConfigError::Address(address.to_string()))?; Ok(SocketAddr::new(ip, port)) } + +fn parse_originated_route(route: &OriginatedRoute) -> Result { + let prefix = IpPrefix::try_from(route.prefix.as_str())?; + let origin = match route.origin.to_ascii_uppercase().as_str() { + "" | "IGP" => OriginPathAttribute::IGP, + "EGP" => OriginPathAttribute::EGP, + "INCOMPLETE" => OriginPathAttribute::INCOMPLETE, + _ => return Err(OriginateProblem::Origin(route.origin.clone())), + }; + let prepend = u8::try_from(route.as_path_prepend) + .ok() + .filter(|&n| u32::from(n) <= MAX_AS_PATH_PREPEND) + .ok_or(OriginateProblem::Prepend { + max: MAX_AS_PATH_PREPEND, + })?; + let communities = route + .communities + .iter() + .map(|c| community::parse_standard(c)) + .collect::>()?; + let large_communities = route + .large_communities + .iter() + .map(|c| community::parse_large(c)) + .collect::>()?; + Ok(LocalRoute::new( + prefix, + LocalRouteAttrs { + origin, + prepend, + med: route.med, + communities, + large_communities, + }, + )) +} + +#[cfg(test)] +mod tests { + use bgp_primitives::ip_prefix::PrefixError; + + use super::*; + + fn parse(text: &str) -> Result { + let proto = protobuf::text_format::parse_from_str::(text) + .expect("the test's textproto is well formed"); + Config::from_proto(proto) + } + + fn rejected(text: &str) -> ConfigError { + parse(text).err().expect(text) + } + + const BASE: &str = "router_id: \"192.0.2.1\" local_asn: 65000\n"; + + #[test] + fn originated_routes_are_parsed_with_their_attributes() { + let config = parse(&format!( + "{BASE}originate {{ prefix: \"192.0.2.0/24\" }} + originate {{ + prefix: \"2001:db8::/32\" origin: \"incomplete\" as_path_prepend: 2 med: 0 + communities: \"65000:100\" communities: \"NO_EXPORT\" + large_communities: \"65000:1:2\" + }}" + )) + .unwrap(); + + assert_eq!(config.originate.len(), 2); + let plain = &config.originate[0]; + assert_eq!(plain.prefix, IpPrefix::try_from("192.0.2.0/24").unwrap()); + assert_eq!(plain.attrs, LocalRouteAttrs::default()); + + let full = &config.originate[1].attrs; + assert_eq!(full.origin, OriginPathAttribute::INCOMPLETE); + assert_eq!(full.prepend, 2); + assert_eq!( + full.med, + Some(0), + "an explicit MED of 0 is not the same as no MED" + ); + assert_eq!(full.communities, vec![(65000, 100), (65535, 65281)]); + assert_eq!(full.large_communities.len(), 1); + } + + #[test] + fn bad_originated_routes_are_rejected_with_the_entry_and_the_reason() { + let problem = |body: &str| match rejected(&format!("{BASE}originate {{ {body} }}")) { + ConfigError::Originate { problem, .. } => problem, + other => panic!("{body}: {other}"), + }; + + assert_eq!( + problem("prefix: \"not-a-prefix\""), + OriginateProblem::Prefix(PrefixError::Format("not-a-prefix".into())) + ); + assert_eq!( + problem("prefix: \"192.0.2.0/24\" origin: \"static\""), + OriginateProblem::Origin("static".into()) + ); + assert_eq!( + problem("prefix: \"192.0.2.0/24\" as_path_prepend: 17"), + OriginateProblem::Prepend { max: 16 } + ); + assert!(matches!( + problem("prefix: \"192.0.2.0/24\" communities: \"70000:1\""), + OriginateProblem::Community(_) + )); + assert!(matches!( + problem("prefix: \"192.0.2.0/24\" large_communities: \"1:2\""), + OriginateProblem::Community(_) + )); + + let duplicate = rejected(&format!( + "{BASE}originate {{ prefix: \"192.0.2.0/24\" }} originate {{ prefix: \"192.0.2.0/24\" med: 5 }}" + )); + assert_eq!( + duplicate, + ConfigError::Originate { + prefix: "192.0.2.0/24".into(), + problem: OriginateProblem::Duplicate + } + ); + // The message names the entry, so the operator can find it. + assert_eq!( + duplicate.to_string(), + "originate \"192.0.2.0/24\": listed more than once" + ); + } + + #[test] + fn a_peer_is_offered_every_address_family_unless_some_are_listed() { + let families = |body: &str| { + let config = parse(&format!( + "{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}" + )) + .unwrap(); + config.peers[0].families.clone() + }; + assert_eq!(families(""), FamilySet::supported()); + assert_eq!( + families("address_families: IPV6_UNICAST"), + [AfiSafi::IPV6_UNICAST].into_iter().collect() + ); + assert_eq!( + families("address_families: IPV4_UNICAST address_families: IPV6_UNICAST"), + FamilySet::supported() + ); + } + + #[test] + fn add_path_is_off_unless_a_peer_asks_for_it() { + let add_path = |body: &str| { + let config = parse(&format!( + "{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {body} }}" + )) + .unwrap(); + config.peers[0].add_path + }; + assert_eq!(add_path(""), None); + assert_eq!(add_path("add_path: ADD_PATH_OFF"), None); + assert_eq!( + add_path("add_path: ADD_PATH_RECEIVE"), + Some(AddPathSendRecv::Receive) + ); + assert_eq!( + add_path("add_path: ADD_PATH_BOTH"), + Some(AddPathSendRecv::Both) + ); + } + + #[test] + fn bad_peers_and_endpoints_are_rejected_with_the_peer_named() { + assert_eq!( + rejected("router_id: \"nope\" local_asn: 1"), + ConfigError::RouterId("nope".into()) + ); + assert_eq!(rejected("router_id: \"192.0.2.1\""), ConfigError::LocalAsn); + + let problem = |body: &str| match rejected(&format!("{BASE}peers {{ name: \"r1\" {body} }}")) + { + ConfigError::Peer { peer, problem } => { + assert_eq!(peer, "r1"); + problem + } + other => panic!("{body}: {other}"), + }; + let endpoint = "endpoint { address: \"192.0.2.2\" }"; + assert_eq!(problem("remote_asn: 1"), PeerProblem::MissingEndpoint); + assert_eq!(problem(endpoint), PeerProblem::RemoteAsn); + assert_eq!( + problem(&format!("{endpoint} remote_asn: 1 hold_time_seconds: 2")), + PeerProblem::HoldTime + ); + assert_eq!( + problem(&format!( + "{endpoint} remote_asn: 1 hold_time_seconds: 70000" + )), + PeerProblem::HoldTime + ); + assert_eq!( + problem(&format!("{endpoint} remote_asn: 1 local_address: \"::1\"")), + PeerProblem::AddressFamilyMismatch + ); + assert_eq!( + problem(&format!( + "{endpoint} remote_asn: 1 address_families: ADDRESS_FAMILY_UNSPECIFIED" + )), + PeerProblem::AddressFamily + ); + assert_eq!( + problem("endpoint { address: \"192.0.2.2\" port: 70000 } remote_asn: 1"), + PeerProblem::Endpoint(Box::new(ConfigError::Port(70000))) + ); + assert_eq!( + rejected(&format!("{BASE}http_endpoint {{ address: \"127.0.0.1\" }}")), + ConfigError::HttpPort + ); + } + + /// Peers are told apart by name where the dialer reports on them and by + /// address when they connect to us; two that share either would be + /// taken for each other. + #[test] + fn peers_must_differ_in_name_and_address() { + let peer = |name: &str, address: &str| { + format!( + "peers {{ name: \"{name}\" endpoint {{ address: \"{address}\" }} remote_asn: 1 }}\n" + ) + }; + let problem = |peers: String| match rejected(&format!("{BASE}{peers}")) { + ConfigError::Peer { problem, .. } => problem, + other => panic!("{other}"), + }; + assert_eq!( + problem(peer("r1", "192.0.2.2") + &peer("r1", "192.0.2.3")), + PeerProblem::DuplicateName + ); + assert_eq!( + problem(peer("r1", "192.0.2.2") + &peer("r2", "192.0.2.2")), + PeerProblem::DuplicateAddress + ); + + // A peer without a name goes by its address, so two of them differ. + let config = parse(&format!( + "{BASE}{}{}", + peer("", "192.0.2.2"), + peer("", "192.0.2.3") + )) + .unwrap(); + assert_eq!(config.peers[0].name, "192.0.2.2"); + assert_eq!(config.peers[1].name, "192.0.2.3"); + assert_eq!( + config + .peer_at("192.0.2.3".parse().unwrap()) + .map(|p| &*p.name), + Some("192.0.2.3") + ); + assert!(config.peer_at("192.0.2.4".parse().unwrap()).is_none()); + } + + /// A peer would refuse our OPEN. + #[test] + fn a_router_id_of_zero_is_rejected() { + assert_eq!( + rejected("router_id: \"0.0.0.0\" local_asn: 1"), + ConfigError::RouterId("0.0.0.0".into()) + ); + } +} diff --git a/crates/server/src/connection.rs b/crates/server/src/connection.rs index fe9690c..78f1c96 100644 --- a/crates/server/src/connection.rs +++ b/crates/server/src/connection.rs @@ -1,17 +1,1048 @@ -use std::net::SocketAddr; +//! Drives one TCP connection through the BGP state machine. +//! +//! [`crate::fsm::Fsm`] decides; this module does. It turns what arrives on +//! the socket, the timers and the rest of the server into +//! [`Event`]s, and carries out the [`Action`]s that come back: writing to +//! the socket, arming timers, registering the session, feeding the RIB. + +use std::collections::VecDeque; +use std::future::pending; +use std::io; +use std::net::{Ipv4Addr, SocketAddr}; +use std::pin::Pin; use std::sync::Arc; +use std::time::Duration; +use bgp_packet::errors::{BgpError, DecodeError, MsgHeaderSubcode}; +use bgp_packet::message::{ + BGP_HEADER_LEN, BGP_MAX_MSG_LEN, Decoded, Message, PathAttribute, UpdateMessage, +}; +use bgp_packet::open::AddPathSendRecv; +use bgp_packet::update_decode::UpdateError; +use bgp_primitives::parser::ParserContext; +use bgp_rib::table::{Outcome, PeerIndex, PeerInfo, RibTable}; +use bytes::BytesMut; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; -use tracing::warn; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::mpsc; +use tokio::time::{Instant, Interval, MissedTickBehavior, Sleep, interval_at, sleep, timeout}; +use tracing::{debug, error, info, warn}; -use crate::fsm::{SessionConfig, run_session}; +use crate::collision::{CollisionTable, ConnectionGuard, Direction}; +use crate::config::PeerConfig; +use crate::error::SessionError; +use crate::family::FamilySet; +use crate::fsm::{Action, Event, Fsm, FsmConfig, Negotiated, Received}; +use crate::metrics::{ + MESSAGES_RECEIVED, MESSAGES_SENT, SESSIONS_ESTABLISHED_TOTAL, UPDATE_ERRORS, message_type_name, +}; +use crate::panic::contained; +use crate::routes; +use crate::session::{PeerId, SessionGuard, SessionHandle, SessionRegistry}; -pub async fn handle_connection( +/// Number of outbound messages that may be queued for a session before +/// senders are back-pressured. +const OUTBOUND_QUEUE_CAPACITY: usize = 1024; + +/// The least time a write to the peer may make no progress before the +/// session is given up: the Send Hold Timer of RFC 9687, which is this or +/// twice the hold time, whichever is longer. A peer that has stopped +/// reading would otherwise hold the session, and our routes, for as long +/// as TCP keeps trying. +const MIN_SEND_HOLD_TIME: Duration = Duration::from_secs(8 * 60); + +/// How long a connection we are closing is kept to let the peer read our +/// NOTIFICATION. Closing at once, with something of the peer's still +/// unread, resets the connection, and the peer may then never see why. +const CLOSE_LINGER: Duration = Duration::from_secs(1); + +/// What every session of a server shares. +pub struct ServerContext { + pub router_id: Ipv4Addr, + pub local_asn: u32, + /// Where routes learned from peers go. + pub rib: Arc, + pub registry: SessionRegistry, + /// Used to resolve connection collisions (RFC 4271 §6.8). + pub collisions: CollisionTable, +} + +/// What is configured for the peer at the other end of a connection. +#[derive(Debug, Clone)] +pub struct PeerSettings { + /// Configured name of the peer, if it is a configured one. + pub name: Option, + /// If set, the peer's OPEN must carry this ASN or the session is + /// refused with a Bad Peer AS notification. + pub expected_asn: Option, + pub hold_time_secs: u16, + /// The address families to offer the peer. + pub families: FamilySet, + /// The directions to offer the peer ADD-PATH for (RFC 7911). + pub add_path: Option, +} + +impl PeerSettings { + /// For a peer that connected to us and is not in the configuration. + pub fn unconfigured(hold_time_secs: u16) -> Self { + Self { + name: None, + expected_asn: None, + hold_time_secs, + families: FamilySet::supported(), + add_path: None, + } + } +} + +impl From<&PeerConfig> for PeerSettings { + fn from(peer: &PeerConfig) -> Self { + Self { + name: Some(peer.name.clone()), + expected_asn: Some(peer.remote_asn), + hold_time_secs: peer.hold_time_secs, + families: peer.families.clone(), + add_path: peer.add_path, + } + } +} + +/// How a session ended. +#[derive(Debug)] +pub struct SessionEnd { + /// Whether it had reached Established. + pub established: bool, + /// `Ok` if it was closed at our own request (a shutdown); otherwise + /// what went wrong, or what the peer said. + pub result: Result<(), SessionError>, +} + +/// Runs the BGP session on `stream` until it ends. `direction` is which +/// side opened the connection. +pub async fn run_session( stream: TcpStream, peer_addr: SocketAddr, - config: Arc, -) { - if let Err(e) = run_session(stream, peer_addr, (*config).clone()).await { - warn!(%peer_addr, err = %e, "BGP session terminated with error"); + direction: Direction, + server: Arc, + peer: PeerSettings, +) -> SessionEnd { + let (mut session, first_actions) = + match Session::open(stream, peer_addr, direction, server, peer) { + Ok(opened) => opened, + Err(e) => { + return SessionEnd { + established: false, + result: Err(e.into()), + }; + } + }; + + let result = match contained(session.run(first_actions)).await { + Ok(result) => result, + Err(panic) => Err(session.close_after_panic(panic).await), + }; + let established = session.established.is_some(); + + // Whatever ended the session, routes learned over it are gone + // (RFC 4271 §8.2.2). Do this before the registration drops so that the + // routes are withdrawn before the session is seen to be down. + if let Some(established) = session.established.take() { + let Established { + peer_id, + routes, + _registration, + .. + } = established; + let outcome = routes.remove().await; + info!( + peer = %peer_id, + removed_routes = outcome.removed, + best_changed = outcome.best_changed, + "Session ended" + ); + } + session.close().await; + SessionEnd { + established, + result, + } +} + +/// What exists only once a session is Established. +struct Established { + peer_id: PeerId, + /// The peer's number in the routing table. + rib_peer: PeerIndex, + /// Messages the rest of the server queued for the peer. + outbound: mpsc::Receiver, + /// The routes learned from the peer, which go when the session does. + routes: PeerRoutes, + /// Keeps the session listed in the registry. + _registration: SessionGuard, +} + +/// The routes a peer has announced on a session, as far as their lifetime +/// goes: they must not outlive it (RFC 4271 §8.2.2). +/// +/// A session that ends removes them with [`PeerRoutes::remove`] and waits, +/// so that their withdrawal is seen before the session is seen to be down. +/// If the session's task is torn down without getting there (a panic, or +/// the task being aborted), dropping this removes them all the same. Left +/// in the table they would be offered as routes of a peer that is gone. +struct PeerRoutes { + table: Arc, + peer: PeerIndex, + removed: bool, +} + +impl PeerRoutes { + fn new(table: Arc, peer: PeerIndex) -> Self { + Self { + table, + peer, + removed: false, + } + } + + /// Removes the routes and says what that did. + async fn remove(mut self) -> Outcome { + self.removed = true; + let (table, peer) = (Arc::clone(&self.table), self.peer); + // Looks at every prefix, which for a full table is a tenth of a + // second: too long to hold up the other sessions on this thread. + tokio::task::spawn_blocking(move || Self::remove_now(&table, peer)) + .await + .unwrap_or_default() + } + + fn remove_now(table: &RibTable, peer: PeerIndex) -> Outcome { + let outcome = table.remove_peer(peer); + // Most of the peer's attribute sets were its alone. + table.attrs().sweep(); + outcome + } +} + +impl Drop for PeerRoutes { + fn drop(&mut self) { + if self.removed { + return; + } + // Not reached in the normal course of things. Drop cannot wait, so + // the removal is handed to a thread that can take its time, if + // there is still a runtime to give it to. + let (table, peer) = (Arc::clone(&self.table), self.peer); + match tokio::runtime::Handle::try_current() { + Ok(runtime) => { + runtime.spawn_blocking(move || { + let outcome = Self::remove_now(&table, peer); + warn!( + removed_routes = outcome.removed, + "Session task ended abnormally; removed its routes" + ); + }); + } + Err(_) => { + Self::remove_now(&table, peer); + } + } + } +} + +struct Session { + fsm: Fsm, + frames: FrameReader, + writer: OwnedWriteHalf, + /// Reused for every message written. + write_buf: BytesMut, + /// How messages from the peer are decoded: the width of AS numbers and + /// where prefixes carry path identifiers are known once the OPENs are + /// exchanged. + ctx: ParserContext, + /// How messages to the peer are encoded. Its own context: whether path + /// identifiers are sent is settled apart from whether they are + /// received (RFC 7911 §4). + send_ctx: ParserContext, + hold_timer: Option>>, + keepalive_timer: Option, + /// How long a write may make no progress; see [`MIN_SEND_HOLD_TIME`]. + send_hold_time: Duration, + /// Whether a NOTIFICATION of ours is on its way to the peer. + notified: bool, + /// This connection's slot in the collision table, once admitted. + collision: Option, + established: Option, + peer_addr: SocketAddr, + local_addr: SocketAddr, + direction: Direction, + server: Arc, + /// Configured name of the peer, if it is a configured one. + peer_name: Option, +} + +impl Session { + /// A session on a connection that has just been opened, and the first + /// things to do on it. + fn open( + stream: TcpStream, + peer_addr: SocketAddr, + direction: Direction, + server: Arc, + peer: PeerSettings, + ) -> io::Result<(Self, Vec)> { + let local_addr = stream.local_addr()?; + let (reader, writer) = stream.into_split(); + let (fsm, first_actions) = Fsm::new(FsmConfig { + router_id: server.router_id, + local_asn: server.local_asn, + hold_time_secs: peer.hold_time_secs, + expected_asn: peer.expected_asn, + families: peer.families, + add_path: peer.add_path, + }); + let session = Session { + fsm, + frames: FrameReader::new(reader), + writer, + write_buf: BytesMut::new(), + ctx: ParserContext::default(), + send_ctx: ParserContext::default(), + hold_timer: None, + keepalive_timer: None, + send_hold_time: MIN_SEND_HOLD_TIME, + notified: false, + collision: None, + established: None, + peer_addr, + local_addr, + direction, + server, + peer_name: peer.name, + }; + Ok((session, first_actions)) + } + + /// Says goodbye to the peer after the session's own code panicked. + /// + /// The state machine may be half way through something, so it is not + /// asked what to do. The socket is still good for a NOTIFICATION, which + /// lets the peer drop our routes now and reconnect, instead of waiting + /// out its hold timer on a connection nobody is reading. + async fn close_after_panic(&mut self, panic: String) -> SessionError { + let error = SessionError::Panicked(panic); + error!(peer_addr = %self.peer_addr, err = %error, "Closing session after a panic"); + if let Some(notification) = error.notification() { + let notification = Message::Notification(notification); + // Contained as well: whatever panicked may do so again. + if !matches!(contained(self.send(¬ification)).await, Ok(Ok(()))) { + warn!(peer_addr = %self.peer_addr, "Could not send NOTIFICATION after a panic"); + } + } + error + } + + /// Closes the connection. If the peer was sent a NOTIFICATION, it is + /// given a moment to read it. + async fn close(mut self) { + // A new connection from the peer must not find this one in its way. + self.collision = None; + if !self.notified { + return; + } + // Our side is done sending; what the peer still sends is read and + // thrown away until it closes its side too. + let _ = self.writer.shutdown().await; + let _ = timeout(CLOSE_LINGER, self.frames.discard_until_closed()).await; + } + + async fn run(&mut self, first_actions: Vec) -> Result<(), SessionError> { + let mut actions = first_actions; + loop { + if let Some(result) = self.execute(actions).await { + return result; + } + let event = self.next_event().await?; + actions = self.fsm.handle(event); + } + } + + /// Waits for whatever happens next. Every branch is cancel-safe: the + /// branches that lose leave nothing half done. + async fn next_event(&mut self) -> Result { + tokio::select! { + frame = self.frames.next(&self.ctx) => match frame { + Ok(decoded) => { + self.report(&decoded.update_errors); + Ok(Event::Message(Received { + message: decoded.message, + attributes_wire: decoded.attributes_wire, + })) + } + Err(SessionError::Decode(e)) => Ok(Event::DecodeFailed(e)), + Err(e) => Err(e), + }, + () = expired(&mut self.hold_timer) => Ok(Event::HoldTimerExpired), + () = ticked(&mut self.keepalive_timer) => Ok(Event::KeepaliveTimerExpired), + msg = queued(&mut self.established) => Ok(Event::Outbound(msg)), + () = killed(&mut self.collision) => Ok(Event::CollisionLost), + } + } + + /// Records errors in an UPDATE that the session survived. They are the + /// peer's fault, or that of a router behind it, and worth an operator's + /// attention: routes were withdrawn or attributes dropped because of + /// them. + fn report(&self, update_errors: &[UpdateError]) { + for error in update_errors { + UPDATE_ERRORS + .with_label_values(&[error.handling.as_str()]) + .inc(); + warn!( + peer_addr = %self.peer_addr, + handling = error.handling.as_str(), + detail = %error.detail, + "Error in received UPDATE" + ); + } + } + + /// Carries out `actions` in order. Returns the session's result once it + /// is over. + async fn execute(&mut self, actions: Vec) -> Option> { + let mut queue: VecDeque = actions.into(); + while let Some(action) = queue.pop_front() { + match action { + Action::Send(msg) => match self.send(&msg).await { + Ok(()) => {} + // The session is ending anyway and the Close that + // follows says why; a NOTIFICATION is best effort. + Err(e) if matches!(msg, Message::Notification(_)) => { + warn!(peer_addr = %self.peer_addr, err = %e, "Failed to send NOTIFICATION"); + } + // A message of our own making that cannot be written. + // That is a bug here, and nothing the peer did: the + // session is worth more than the message. + Err(SessionError::Encode(e)) => { + error!( + peer_addr = %self.peer_addr, + message = message_type_name(&msg), + err = %e, + "Dropped a message that cannot be encoded" + ); + } + Err(e) => return Some(Err(e)), + }, + Action::ResetHoldTimer(duration) => match &mut self.hold_timer { + Some(timer) => timer.as_mut().reset(Instant::now() + duration), + None => self.hold_timer = Some(Box::pin(sleep(duration))), + }, + Action::StopHoldTimer => self.hold_timer = None, + Action::StartKeepaliveTimer(every) => { + // interval_at, so that the first tick is one interval + // from now and not immediate. + let mut timer = interval_at(Instant::now() + every, every); + // After a stall, one KEEPALIVE and not one for every + // tick that was missed. + timer.set_missed_tick_behavior(MissedTickBehavior::Delay); + self.keepalive_timer = Some(timer); + } + Action::Admit(negotiated) => { + if !self.admit(&negotiated) { + // What the machine wanted next no longer applies. + queue = self.fsm.handle(Event::CollisionLost).into(); + } + } + Action::Established(negotiated) => { + if let Err(e) = self.establish(&negotiated) { + // What the machine wanted next no longer applies. + error!(peer_addr = %self.peer_addr, err = %e, "Cannot take the peer's routes"); + queue = self.fsm.handle(Event::LocalFailure(e)).into(); + } + } + Action::RoutesIgnored(count) => { + warn!( + peer_addr = %self.peer_addr, + routes = count, + "Ignored routes of an address family that was not negotiated" + ); + } + Action::DeliverUpdate { + update, + attributes_wire, + } => { + if let Err(e) = self.store(&update, attributes_wire.as_deref()) { + error!(peer_addr = %self.peer_addr, err = %e, "Cannot store the peer's routes"); + queue = self.fsm.handle(Event::LocalFailure(e)).into(); + } + } + Action::RouteRefreshRequested => { + if let Some(established) = &self.established { + info!(peer = %established.peer_id, "Received ROUTE-REFRESH, re-sending our routes"); + self.server.registry.request_refresh(&established.peer_id); + } + } + Action::Close(result) => return Some(result), + } + } + None + } + + /// Takes what the OPENs settled into use, and asks collision detection + /// whether this connection may go on. Returns whether it may. + fn admit(&mut self, negotiated: &Negotiated) -> bool { + self.ctx = ParserContext { + four_octet_asn: negotiated.four_octet_asn, + ibgp: negotiated.ibgp, + add_path: negotiated.add_path_receive.iter().collect(), + }; + self.send_ctx = ParserContext { + add_path: negotiated.add_path_send.iter().collect(), + ..self.ctx.clone() + }; + let hold_time = Duration::from_secs(negotiated.hold_time_secs.into()); + self.send_hold_time = MIN_SEND_HOLD_TIME.max(2 * hold_time); + + self.collision = self.server.collisions.admit( + self.server.router_id, + self.peer_addr.ip(), + negotiated.peer_identifier, + self.direction, + ); + if self.collision.is_none() { + info!(peer_addr = %self.peer_addr, "Closing connection: lost collision resolution"); + return false; + } + info!( + peer_addr = %self.peer_addr, + peer_asn = negotiated.peer_asn, + negotiated_hold_time = negotiated.hold_time_secs, + four_octet_asn = negotiated.four_octet_asn, + families = %negotiated.families, + add_path_receive = %negotiated.add_path_receive, + add_path_send = %negotiated.add_path_send, + direction = self.direction.as_str(), + "OPEN received, transitioning to OpenConfirm" + ); + true + } + + /// Makes room for the peer's routes and makes the session visible to + /// the rest of the server. + fn establish(&mut self, negotiated: &Negotiated) -> Result<(), SessionError> { + let peer_id = PeerId { + addr: self.peer_addr, + asn: negotiated.peer_asn, + }; + let rib_peer = self.server.rib.add_peer(PeerInfo { + asn: negotiated.peer_asn, + router_id: negotiated.peer_identifier, + address: self.peer_addr.ip(), + ibgp: negotiated.ibgp, + })?; + info!(peer_addr = %self.peer_addr, peer_asn = negotiated.peer_asn, "Session established"); + SESSIONS_ESTABLISHED_TOTAL.inc(); + + if let Some(collision) = &mut self.collision { + collision.set_established(); + } + let (outbound_tx, outbound) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY); + let registration = self.server.registry.register(SessionHandle::new( + peer_id, + self.peer_name.clone(), + self.direction, + self.local_addr, + negotiated, + rib_peer, + outbound_tx, + )); + self.established = Some(Established { + peer_id, + rib_peer, + outbound, + routes: PeerRoutes::new(Arc::clone(&self.server.rib), rib_peer), + _registration: registration, + }); + Ok(()) + } + + /// Puts the routes of a received UPDATE into the routing table. + fn store( + &self, + update: &UpdateMessage, + attributes_wire: Option<&[u8]>, + ) -> Result<(), SessionError> { + let Some(established) = &self.established else { + return Ok(()); + }; + let outcome = routes::apply( + &self.server.rib, + established.rib_peer, + update, + attributes_wire, + )?; + // One line for every UPDATE is too much to log by default: a full + // table is a million of them. + debug!( + peer = %established.peer_id, + added = outcome.added, + replaced = outcome.replaced, + removed = outcome.removed, + best_changed = outcome.best_changed, + "Received UPDATE" + ); + Ok(()) + } + + async fn send(&mut self, msg: &Message) -> Result<(), SessionError> { + if let (Message::Update(update), Some(established)) = (msg, &self.established) { + let (withdrawn, announced) = prefix_counts(update); + debug!(peer = %established.peer_id, withdrawn, announced, "Sending UPDATE"); + } + if let Message::Notification(notification) = msg { + info!(peer_addr = %self.peer_addr, error = %notification.error, "Sending NOTIFICATION, closing session"); + self.notified = true; + } + self.write_buf.clear(); + msg.to_wire(&self.send_ctx, &mut self.write_buf)?; + match timeout(self.send_hold_time, self.writer.write_all(&self.write_buf)).await { + Ok(written) => written?, + Err(_) => return Err(SessionError::SendHoldTimerExpired(self.send_hold_time)), + } + MESSAGES_SENT + .with_label_values(&[message_type_name(msg)]) + .inc(); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Event sources. Each waits forever when its source does not exist yet, so +// that it can sit in the select! from the start. +// --------------------------------------------------------------------------- + +async fn expired(timer: &mut Option>>) { + match timer { + Some(timer) => timer.await, + None => pending().await, + } +} + +async fn ticked(timer: &mut Option) { + match timer { + Some(timer) => { + timer.tick().await; + } + None => pending().await, + } +} + +async fn queued(established: &mut Option) -> Message { + match established { + // The registry holds the sending half for as long as the session is + // registered, so the queue does not close under us; if it somehow + // did, there is simply nothing more to send. + Some(established) => match established.outbound.recv().await { + Some(msg) => msg, + None => pending().await, + }, + None => pending().await, + } +} + +async fn killed(collision: &mut Option) { + match collision { + Some(guard) => guard.killed().await, + None => pending().await, + } +} + +// --------------------------------------------------------------------------- +// Framing +// --------------------------------------------------------------------------- + +/// Reads whole BGP messages from a byte stream. +/// +/// Bytes are collected in a buffer until a full message is there, so +/// [`FrameReader::next`] can be dropped at any point and called again +/// without losing data. That matters because it shares a `select!` with the +/// timers: a message that arrives in two pieces must survive a keepalive +/// timer firing in between. +struct FrameReader { + reader: R, + buf: BytesMut, +} + +impl FrameReader { + fn new(reader: R) -> Self { + Self { + reader, + buf: BytesMut::with_capacity(usize::from(BGP_MAX_MSG_LEN)), + } + } + + /// The next message from the peer. Cancel-safe. + /// + /// With an UPDATE come the errors in it that were handled without + /// resetting the session (RFC 7606); the UPDATE returned is the one to + /// act on, with their handling already applied. + async fn next(&mut self, ctx: &ParserContext) -> Result { + loop { + if let Some(length) = frame_length(&self.buf)? + && self.buf.len() >= length + { + let frame = self.buf.split_to(length); + let decoded = Message::decode_with_report(ctx, &frame)?; + MESSAGES_RECEIVED + .with_label_values(&[message_type_name(&decoded.message)]) + .inc(); + return Ok(decoded); + } + if self.reader.read_buf(&mut self.buf).await? == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "peer closed the connection", + ) + .into()); + } + } + } + + /// Reads and throws away whatever the peer sends, until it closes the + /// connection or the connection fails. + async fn discard_until_closed(&mut self) { + loop { + self.buf.clear(); + if !matches!(self.reader.read_buf(&mut self.buf).await, Ok(read) if read > 0) { + return; + } + } + } +} + +/// The length of the message at the front of `buf`, once its header has +/// arrived. The length decides how much more to wait for, so it is checked +/// here, before the rest of the message exists (RFC 4271 §6.1). +fn frame_length(buf: &[u8]) -> Result, DecodeError> { + if buf.len() < usize::from(BGP_HEADER_LEN) { + return Ok(None); + } + let length = u16::from_be_bytes([buf[16], buf[17]]); + if !(BGP_HEADER_LEN..=BGP_MAX_MSG_LEN).contains(&length) { + return Err(DecodeError { + notification: BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength), + data: length.to_be_bytes().to_vec(), + detail: format!( + "BGP message length {length} is not between {BGP_HEADER_LEN} and {BGP_MAX_MSG_LEN}" + ), + }); + } + Ok(Some(usize::from(length))) +} + +/// Prefixes withdrawn and announced by an UPDATE, across the IPv4 fields in +/// the message body and the multiprotocol attributes (where IPv6 travels). +fn prefix_counts(update: &UpdateMessage) -> (usize, usize) { + let mut withdrawn = update.withdrawn_routes.len(); + let mut announced = update.announced_routes.len(); + for attr in &update.path_attributes { + match attr { + PathAttribute::MpReachNlri(mp) => announced += mp.prefixes.len(), + PathAttribute::MpUnreachNlri(mp) => withdrawn += mp.prefixes.len(), + _ => {} + } + } + (withdrawn, announced) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use bgp_packet::errors::CeaseSubcode; + use bgp_packet::message::NotificationMessage; + use proptest::collection::vec; + use proptest::prelude::*; + use tokio::io::{AsyncWriteExt, duplex}; + + use super::*; + + const KEEPALIVE: [u8; 19] = [ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x00, 0x13, 0x04, + ]; + + #[tokio::test] + async fn messages_are_split_out_of_the_stream_however_they_arrive() { + let (mut peer, ours) = duplex(4096); + let mut frames = FrameReader::new(ours); + let ctx = ParserContext::default(); + + // Two and a half messages in one write, the rest in another. + let mut bytes = Vec::new(); + for _ in 0..3 { + bytes.extend_from_slice(&KEEPALIVE); + } + peer.write_all(&bytes[..47]).await.unwrap(); + assert!(matches!( + frames.next(&ctx).await, + Ok(Decoded { + message: Message::KeepAlive, + .. + }) + )); + assert!(matches!( + frames.next(&ctx).await, + Ok(Decoded { + message: Message::KeepAlive, + .. + }) + )); + peer.write_all(&bytes[47..]).await.unwrap(); + assert!(matches!( + frames.next(&ctx).await, + Ok(Decoded { + message: Message::KeepAlive, + .. + }) + )); + + drop(peer); + assert!(matches!( + frames.next(&ctx).await, + Err(SessionError::Io(e)) if e.kind() == io::ErrorKind::UnexpectedEof + )); + } + + /// The old reader used read_exact inside select!: when a timer fired + /// while a message was half read, the half was thrown away and the + /// stream was out of step from then on. + #[tokio::test] + async fn a_read_abandoned_half_way_loses_nothing() { + let (mut peer, ours) = duplex(4096); + let mut frames = FrameReader::new(ours); + let ctx = ParserContext::default(); + + peer.write_all(&KEEPALIVE[..10]).await.unwrap(); + // Something else wins the race, as a timer would in the session. + let abandoned = tokio::time::timeout(Duration::from_millis(20), frames.next(&ctx)).await; + assert!(abandoned.is_err(), "half a message is not a message"); + + peer.write_all(&KEEPALIVE[10..]).await.unwrap(); + assert!(matches!( + frames.next(&ctx).await, + Ok(Decoded { + message: Message::KeepAlive, + .. + }) + )); + } + + /// A bug in the session must not leave the peer talking to a connection + /// nobody reads: it is told the session is being reset. + #[tokio::test] + async fn a_session_that_panicked_still_says_goodbye() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut peer = TcpStream::connect(listener.local_addr().unwrap()) + .await + .unwrap(); + let (stream, peer_addr) = listener.accept().await.unwrap(); + let server = Arc::new(ServerContext { + router_id: Ipv4Addr::new(10, 0, 0, 1), + local_asn: 65000, + rib: Arc::new(RibTable::new(4)), + registry: SessionRegistry::new(), + collisions: CollisionTable::new(false), + }); + let (mut session, _) = Session::open( + stream, + peer_addr, + Direction::Inbound, + server, + PeerSettings::unconfigured(90), + ) + .unwrap(); + + let error = session + .close_after_panic("index out of bounds".into()) + .await; + assert_eq!( + error.to_string(), + "the session panicked: index out of bounds" + ); + drop(session); + + // Cease / Administrative Reset, then the end of the stream. + let mut received = Vec::new(); + peer.read_to_end(&mut received).await.unwrap(); + let mut expected = vec![0xff; 16]; + expected.extend_from_slice(&[0, 21, 3, 6, 4]); + assert_eq!(received, expected); + } + + /// A peer that stops reading must not hold the session for as long as + /// TCP will keep trying: a write that makes no progress is given up + /// (RFC 9687). Nothing else would end it, since the session reads + /// nothing, timers included, while it waits to write. + #[tokio::test] + async fn a_peer_that_stops_reading_is_given_up_on() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + // Held, and never read from. + let _peer = TcpStream::connect(listener.local_addr().unwrap()) + .await + .unwrap(); + let (stream, peer_addr) = listener.accept().await.unwrap(); + let server = Arc::new(ServerContext { + router_id: Ipv4Addr::new(10, 0, 0, 1), + local_asn: 65000, + rib: Arc::new(RibTable::new(4)), + registry: SessionRegistry::new(), + collisions: CollisionTable::new(false), + }); + let (mut session, _) = Session::open( + stream, + peer_addr, + Direction::Inbound, + server, + PeerSettings::unconfigured(90), + ) + .unwrap(); + session.send_hold_time = Duration::from_millis(100); + + // Until the socket's buffers are full, writes succeed. + let filler = Message::Notification(NotificationMessage { + error: BgpError::Cease(CeaseSubcode::AdminShutdown), + data: vec![0; 4000], + }); + let stuck = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Err(e) = session.send(&filler).await { + return e; + } + } + }) + .await + .expect("a write that cannot finish is given up"); + assert!( + matches!(stuck, SessionError::SendHoldTimerExpired(_)), + "{stuck}" + ); + assert!(stuck.notification().is_none(), "there is no way to say so"); + } + + /// A session task that is torn down without running its cleanup must + /// not leave the peer's routes behind. + #[tokio::test] + async fn routes_are_removed_even_if_the_session_never_cleans_up() { + let table = Arc::new(RibTable::new(4)); + let peer = table + .add_peer(PeerInfo { + asn: 65001, + router_id: Ipv4Addr::new(10, 0, 0, 2), + address: Ipv4Addr::new(192, 0, 2, 1).into(), + ibgp: false, + }) + .unwrap(); + let update = UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![], + announced_routes: vec![ + bgp_primitives::ip_prefix::IpPrefix::try_from("10.0.0.0/8") + .unwrap() + .into(), + ], + }; + routes::apply(&table, peer, &update, None).unwrap(); + assert_eq!(table.len(), 1); + assert_eq!(table.attrs().len(), 1); + + // As when a panic unwinds the session's task. + drop(PeerRoutes::new(Arc::clone(&table), peer)); + + tokio::time::timeout(Duration::from_secs(5), async { + while !table.is_empty() || !table.attrs().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the routes and their attributes are removed"); + } + + proptest! { + /// TCP delivers a stream, not messages: however the bytes of a run + /// of messages are cut into reads, the same messages come out. + #[test] + fn framing_does_not_depend_on_how_the_stream_is_cut( + bodies in vec(vec(any::(), 0..60), 1..8), + cuts in vec(1usize..40, 0..30), + ) { + // NOTIFICATIONs, since their body may be any bytes after a + // valid code and subcode. + let sent: Vec = bodies + .into_iter() + .map(|data| { + Message::Notification(NotificationMessage { + error: BgpError::Cease(CeaseSubcode::AdminShutdown), + data, + }) + }) + .collect(); + let ctx = ParserContext::default(); + let mut stream = BytesMut::new(); + for msg in &sent { + msg.to_wire(&ctx, &mut stream).unwrap(); + } + + let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); + let received = runtime.block_on(async { + let (mut peer, ours) = duplex(8192); + let mut frames = FrameReader::new(ours); + let writer = async { + let mut rest = &stream[..]; + for cut in cuts.iter().copied().chain(std::iter::repeat(40)) { + if rest.is_empty() { + break; + } + let (now, later) = rest.split_at(cut.min(rest.len())); + peer.write_all(now).await.unwrap(); + // Let the reader see this piece on its own. + tokio::task::yield_now().await; + rest = later; + } + drop(peer); + }; + let reader = async { + let mut received = Vec::new(); + while let Ok(Decoded { message: msg, .. }) = frames.next(&ctx).await { + received.push(msg); + } + received + }; + tokio::join!(writer, reader).1 + }); + prop_assert_eq!(received, sent); + } + } + + #[tokio::test] + async fn an_impossible_length_is_rejected_from_the_header_alone() { + let (mut peer, ours) = duplex(4096); + let mut frames = FrameReader::new(ours); + + let mut header = KEEPALIVE; + header[16..18].copy_from_slice(&5000u16.to_be_bytes()); + peer.write_all(&header).await.unwrap(); + + // No waiting for 5000 bytes that should never be sent. + match frames.next(&ParserContext::default()).await { + Err(SessionError::Decode(e)) => { + assert_eq!( + e.notification, + BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength) + ); + assert_eq!(e.data, 5000u16.to_be_bytes()); + } + other => panic!("expected a decode error, got {other:?}"), + } } } diff --git a/crates/server/src/dialer.rs b/crates/server/src/dialer.rs new file mode 100644 index 0000000..c35a37b --- /dev/null +++ b/crates/server/src/dialer.rs @@ -0,0 +1,116 @@ +//! Active side of the FSM: dial each configured peer and keep the session up. +//! +//! One task per configured peer loops forever: connect, run the session, +//! back off, repeat. While a session with the same remote IP and ASN is +//! already established (for example because the peer dialled us first) the +//! dialer stays idle rather than opening a second one. When both sides dial +//! at once the FSM resolves the collision (see [`crate::collision`]); if our +//! connection is the one closed, the next pass finds the surviving session +//! and goes idle. + +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use tokio::net::{TcpSocket, TcpStream}; +use tokio::task::JoinHandle; +use tracing::{info, warn}; + +use crate::collision::Direction; +use crate::config::PeerConfig; +use crate::connection::{PeerSettings, ServerContext, run_session}; +use crate::session::DialState; + +/// Upper bound for exponential backoff between connection attempts. +const MAX_BACKOFF: Duration = Duration::from_secs(120); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn spawn_dialers( + peers: &[PeerConfig], + server: &Arc, + connect_retry: Duration, +) -> Vec> { + peers + .iter() + .map(|peer| tokio::spawn(dial_loop(peer.clone(), Arc::clone(server), connect_retry))) + .collect() +} + +/// Opens the TCP connection, from the configured source address if any. +async fn connect(peer: &PeerConfig) -> std::io::Result { + let Some(local) = peer.local_address else { + return TcpStream::connect(peer.endpoint).await; + }; + let socket = if peer.endpoint.is_ipv4() { + TcpSocket::new_v4()? + } else { + TcpSocket::new_v6()? + }; + socket + .bind(std::net::SocketAddr::new(local, 0)) + .map_err(|e| { + std::io::Error::new(e.kind(), format!("cannot use local_address {local}: {e}")) + })?; + socket.connect(peer.endpoint).await +} + +async fn dial_loop(peer: PeerConfig, server: Arc, connect_retry: Duration) { + let registry = &server.registry; + let mut backoff = connect_retry; + + loop { + if registry.is_shutting_down() { + return; + } + if registry.find(peer.endpoint.ip(), peer.remote_asn).is_some() { + // Peer dialled us; nothing to do until that session goes away. + registry.set_dial_state(&peer.name, DialState::Idle); + tokio::time::sleep(connect_retry).await; + continue; + } + + registry.set_dial_state(&peer.name, DialState::Connecting); + info!(peer = %peer.name, endpoint = %peer.endpoint, "Connecting to peer"); + + let ended = match tokio::time::timeout(CONNECT_TIMEOUT, connect(&peer)).await { + Ok(Ok(stream)) => { + info!(peer = %peer.name, endpoint = %peer.endpoint, "TCP connected, starting session"); + let end = run_session( + stream, + peer.endpoint, + Direction::Outbound, + Arc::clone(&server), + PeerSettings::from(&peer), + ) + .await; + // A session that came up is a peer worth dialling again + // soon. One that connects and is then refused, for its AS + // number say, will be refused again: it is retried ever + // more slowly, like one that cannot be reached at all. + if end.established { + backoff = connect_retry; + } + end.result.map_err(|e| e.to_string()) + } + Ok(Err(e)) => Err(format!("connect failed: {e}")), + Err(_) => Err(format!("connect timed out after {CONNECT_TIMEOUT:?}")), + }; + + let reason = match ended { + Ok(()) => "session closed at our request".to_string(), + Err(reason) => { + warn!(peer = %peer.name, endpoint = %peer.endpoint, err = %reason, "Session attempt ended"); + reason + } + }; + + registry.set_dial_state( + &peer.name, + DialState::Backoff { + until: SystemTime::now() + backoff, + last_error: reason, + }, + ); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(MAX_BACKOFF); + } +} diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs new file mode 100644 index 0000000..e20a3cd --- /dev/null +++ b/crates/server/src/error.rs @@ -0,0 +1,248 @@ +//! Error types of the server crate. + +use std::io; +use std::net::SocketAddr; +use std::time::Duration; + +use bgp_packet::errors::{BgpError, CeaseSubcode, DecodeError, FsmSubcode, OpenMsgSubcode}; +use bgp_packet::message::NotificationMessage; +use bgp_primitives::parser::EncodeError; +use bgp_rib::RibStopped; +use bgp_rib::table::TooManyPeers; +use thiserror::Error; + +use crate::family::FamilySet; +use crate::fsm::State; + +/// Why a session ended abnormally. +#[derive(Debug, Error)] +pub enum SessionError { + #[error("connection failed: {0}")] + Io(#[from] io::Error), + #[error("received a message that could not be decoded: {0}")] + Decode(#[from] DecodeError), + #[error("could not encode a message: {0}")] + Encode(#[from] EncodeError), + /// The peer sent nothing for as long as the pre-negotiation hold time. + #[error("timed out waiting for a message in the {0} state")] + Timeout(State), + #[error("hold timer expired")] + HoldTimerExpired, + #[error("received {received} in the {state} state")] + UnexpectedMessage { + state: State, + received: &'static str, + }, + /// The peer ended the session. Nothing is sent in return. + #[error("peer sent NOTIFICATION: {0}")] + NotifiedByPeer(BgpError), + #[error("refused the peer's OPEN: {0}")] + OpenRefused(BgpError), + #[error("peer announced AS{actual} but AS{expected} was configured")] + WrongPeerAs { expected: u32, actual: u32 }, + #[error("closed by connection collision resolution")] + Collision, + /// The routing table has no room for another peer. + #[error(transparent)] + TableFull(#[from] TooManyPeers), + /// A bug: the session's own code panicked. The peer is told the session + /// is being reset, and the session is cleaned up like any other. + #[error("the session panicked: {0}")] + Panicked(String), + /// The peer offers none of the address families configured for it, so + /// the session could carry nothing. + #[error("no address family in common: we offer {ours}, the peer offers {theirs}")] + NoCommonAddressFamily { ours: FamilySet, theirs: FamilySet }, + /// The peer took nothing we sent for this long (RFC 9687): it has + /// stopped reading, and there is no telling it so. + #[error("the peer accepted no data for {0:?}")] + SendHoldTimerExpired(Duration), +} + +impl SessionError { + /// The NOTIFICATION that tells the peer about this error (RFC 4271 §6), + /// or `None` when there is nothing to say or no way to say it. + pub fn notification(&self) -> Option { + let error = match self { + Self::Io(_) | Self::SendHoldTimerExpired(_) | Self::NotifiedByPeer(_) => return None, + // Something of ours could not be written out, which is a bug + // here: as for a panic, the session is reset. + Self::Encode(_) => BgpError::Cease(CeaseSubcode::AdminReset), + Self::Decode(e) => { + return Some(NotificationMessage { + error: e.notification.clone(), + data: e.data.clone(), + }); + } + Self::Timeout(_) | Self::HoldTimerExpired => BgpError::HoldTimer, + Self::UnexpectedMessage { state, .. } => BgpError::FsmError(match state { + State::OpenSent => FsmSubcode::UnexpectedOpenSent, + State::OpenConfirm => FsmSubcode::UnexpectedOpenConfirm, + // A closed machine handles no messages; Established is the + // only other state one can arrive in. + State::Established | State::Closed => FsmSubcode::UnexpectedEstablished, + }), + Self::OpenRefused(error) => error.clone(), + Self::WrongPeerAs { .. } => BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), + Self::Collision => BgpError::Cease(CeaseSubcode::ConnCollisionResolution), + Self::TableFull(_) => BgpError::Cease(CeaseSubcode::OutOfResources), + // The session will be set up again; nothing is wrong at the + // peer's end. + Self::Panicked(_) => BgpError::Cease(CeaseSubcode::AdminReset), + // RFC 5492 §5: the Data lists the capabilities we need and the + // peer did not offer. + Self::NoCommonAddressFamily { ours, .. } => { + return Some(NotificationMessage { + error: BgpError::OpenMsg(OpenMsgSubcode::UnsupportedCapability), + data: ours.capabilities_wire(), + }); + } + }; + Some(NotificationMessage { + error, + data: vec![], + }) + } +} + +/// Why the server could not start, or could not go on. +#[derive(Debug, Error)] +pub enum ServerError { + #[error("failed to bind listener on {addr}: {source}")] + Bind { + addr: SocketAddr, + #[source] + source: io::Error, + }, + #[error("listener failed: {0}")] + Io(#[from] io::Error), + /// A RIB's actor died. The server cannot work without its tables. + #[error(transparent)] + RibStopped(#[from] RibStopped), + /// A task the server depends on ended, which none of them do unless + /// they panic. + #[error("the {task} task stopped unexpectedly: {reason}")] + TaskStopped { task: &'static str, reason: String }, +} + +/// Why a configuration was rejected. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ConfigError { + #[error("invalid router_id {0:?}: must be a dotted-quad IPv4 address other than 0.0.0.0")] + RouterId(String), + #[error("local_asn must be non-zero")] + LocalAsn, + #[error("invalid IP address {0:?}")] + Address(String), + #[error("port {0} is out of range")] + Port(u32), + #[error("http_endpoint.port is required")] + HttpPort, + #[error("peer {peer:?}: {problem}")] + Peer { peer: String, problem: PeerProblem }, + #[error("originate {prefix:?}: {problem}")] + Originate { + prefix: String, + problem: OriginateProblem, + }, +} + +/// What is wrong with one `peers` entry. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PeerProblem { + #[error("endpoint is missing")] + MissingEndpoint, + #[error("endpoint is invalid: {0}")] + Endpoint(Box), + #[error("remote_asn must be non-zero")] + RemoteAsn, + #[error("hold_time_seconds must be 0 or at least 3")] + HoldTime, + #[error("there is another peer of this name")] + DuplicateName, + #[error("there is another peer at this address")] + DuplicateAddress, + #[error("local_address {0:?} is not an IP address")] + LocalAddress(String), + #[error("local_address and endpoint are different address families")] + AddressFamilyMismatch, + #[error("address_families has a value that is not an address family")] + AddressFamily, + #[error("add_path has a value that is not one of its settings")] + AddPath, +} + +/// What is wrong with one `originate` entry. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum OriginateProblem { + #[error("{0}")] + Prefix(#[from] bgp_primitives::ip_prefix::PrefixError), + #[error("origin {0:?} must be IGP, EGP or INCOMPLETE")] + Origin(String), + #[error("as_path_prepend must be at most {max}")] + Prepend { max: u32 }, + #[error("{0}")] + Community(#[from] bgp_packet::community::CommunityError), + #[error("listed more than once")] + Duplicate, +} + +#[cfg(test)] +mod tests { + use bgp_packet::errors::MsgHeaderSubcode; + + use super::*; + + #[test] + fn each_session_error_names_the_notification_rfc_4271_assigns_it() { + let notification = |e: SessionError| e.notification().map(|n| (n.error, n.data)); + + assert_eq!( + notification(SessionError::Decode(DecodeError { + notification: BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength), + data: vec![0x13, 0x88], + detail: "too long".into(), + })), + Some(( + BgpError::MsgHeader(MsgHeaderSubcode::BadMessageLength), + vec![0x13, 0x88] + )) + ); + assert_eq!( + notification(SessionError::Timeout(State::OpenSent)), + Some((BgpError::HoldTimer, vec![])) + ); + assert_eq!( + notification(SessionError::UnexpectedMessage { + state: State::OpenConfirm, + received: "update" + }), + Some(( + BgpError::FsmError(FsmSubcode::UnexpectedOpenConfirm), + vec![] + )) + ); + assert_eq!( + notification(SessionError::WrongPeerAs { + expected: 1, + actual: 2 + }), + Some((BgpError::OpenMsg(OpenMsgSubcode::BadPeerAs), vec![])) + ); + // Something of ours that cannot be written is our fault: the peer + // is told the session is being reset, not left to time it out. + assert_eq!( + notification(SessionError::Encode(EncodeError::TooLong { + what: "path attribute", + len: 70000, + max: 65535 + })), + Some((BgpError::Cease(CeaseSubcode::AdminReset), vec![])) + ); + // A broken connection cannot carry a NOTIFICATION. + assert_eq!( + notification(SessionError::Io(io::ErrorKind::BrokenPipe.into())), + None + ); + } +} diff --git a/crates/server/src/family.rs b/crates/server/src/family.rs new file mode 100644 index 0000000..1024417 --- /dev/null +++ b/crates/server/src/family.rs @@ -0,0 +1,292 @@ +//! The address families a session can carry, and which of them it does. +//! +//! Each side lists the families it is willing to exchange in its OPEN, as +//! Multiprotocol capabilities (RFC 4760 §8). A family is in use on a session +//! only if both sides listed it. +//! +//! Any family can be named, read from a peer and held in a [`FamilySet`]. +//! The ones this speaker can actually exchange routes for are [`SUPPORTED`], +//! and only those are ever offered to a peer. + +use std::collections::BTreeSet; +use std::fmt; + +use bgp_packet::message::{PathAttribute, UpdateMessage}; +use bgp_packet::open::{Capability, OpenMessage}; +use bgp_primitives::constants::AfiSafi; +use bgp_primitives::ip_prefix::IpPrefix; +use bytes::BytesMut; + +/// The address families this speaker can exchange routes for. Supporting a +/// new one means being able to parse, store and advertise its routes; then +/// it is added here. +pub const SUPPORTED: [AfiSafi; 2] = [AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST]; + +/// A set of address families. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FamilySet(BTreeSet); + +impl FamilySet { + /// Every family in [`SUPPORTED`]. + pub fn supported() -> Self { + SUPPORTED.into_iter().collect() + } + + pub fn insert(&mut self, family: AfiSafi) { + self.0.insert(family); + } + + pub fn contains(&self, family: AfiSafi) -> bool { + self.0.contains(&family) + } + + /// Whether a unicast route for `prefix` may be exchanged. + pub fn carries(&self, prefix: &IpPrefix) -> bool { + self.contains(AfiSafi::unicast(prefix.address_family())) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn intersection(&self, other: &Self) -> Self { + Self(self.0.intersection(&other.0).copied().collect()) + } + + pub fn iter(&self) -> impl Iterator + '_ { + self.0.iter().copied() + } + + /// The families a peer offered in its OPEN, whether or not we support + /// them. + /// + /// A peer that sends no Multiprotocol capability at all is a speaker of + /// plain RFC 4271 BGP, which carries IPv4 unicast and nothing else. One + /// that sends any has listed everything it wants, IPv4 unicast included. + pub fn offered_by(open: &OpenMessage) -> Self { + let listed: Self = open + .capabilities() + .filter_map(|capability| match capability { + Capability::MultiProtocol { afi, safi } => Some(AfiSafi::new(*afi, *safi)), + _ => None, + }) + .collect(); + if listed.is_empty() { + [AfiSafi::IPV4_UNICAST].into_iter().collect() + } else { + listed + } + } + + /// These families as Multiprotocol capabilities in wire form: the Data + /// of an Unsupported Capability NOTIFICATION (RFC 5492 §5). + pub fn capabilities_wire(&self) -> Vec { + let mut out = BytesMut::new(); + for family in self.iter() { + // A Multiprotocol capability always encodes; if that ever + // changed, a NOTIFICATION with less Data is still correct. + if Capability::multiprotocol(family).to_wire(&mut out).is_err() { + return vec![]; + } + } + out.to_vec() + } + + /// Removes from `update` every route of a family that is not in this + /// set, and returns how many prefixes that was. + /// + /// The withdrawn routes and NLRI in the body of an UPDATE are IPv4 + /// unicast; every other family travels in MP_REACH_NLRI and + /// MP_UNREACH_NLRI, which name theirs. + pub fn restrict(&self, update: &mut UpdateMessage) -> usize { + let mut removed = 0; + if !self.contains(AfiSafi::IPV4_UNICAST) { + removed += update.withdrawn_routes.len() + update.announced_routes.len(); + update.withdrawn_routes.clear(); + update.announced_routes.clear(); + } + update.path_attributes.retain(|attribute| { + let (family, prefixes) = match attribute { + PathAttribute::MpReachNlri(mp) => (mp.family, mp.prefixes.len()), + PathAttribute::MpUnreachNlri(mp) => (mp.family, mp.prefixes.len()), + _ => return true, + }; + let keep = self.contains(family); + if !keep { + removed += prefixes; + } + keep + }); + removed + } +} + +impl FromIterator for FamilySet { + fn from_iter>(families: I) -> Self { + Self(families.into_iter().collect()) + } +} + +impl fmt::Display for FamilySet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_empty() { + return f.write_str("none"); + } + for (i, family) in self.iter().enumerate() { + if i > 0 { + f.write_str(", ")?; + } + write!(f, "{family}")?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::net::{Ipv4Addr, Ipv6Addr}; + + use bgp_packet::message::{ + MpReachNlriPathAttribute, MpUnreachNlriPathAttribute, NlriNextHop, OriginPathAttribute, + }; + use bgp_packet::open::OpenOption; + use bgp_primitives::constants::{Afi, Safi}; + + use super::*; + + fn open(capabilities: Vec) -> OpenMessage { + OpenMessage { + version: 4, + asn: 65001, + hold_time: 90, + identifier: Ipv4Addr::new(10, 0, 0, 2), + options: if capabilities.is_empty() { + vec![] + } else { + vec![OpenOption::Capabilities(capabilities)] + }, + } + } + + fn prefix(s: &str) -> IpPrefix { + IpPrefix::try_from(s).unwrap() + } + + fn only(family: AfiSafi) -> FamilySet { + [family].into_iter().collect() + } + + #[test] + fn a_peer_without_multiprotocol_capabilities_speaks_ipv4_unicast() { + assert_eq!( + FamilySet::offered_by(&open(vec![])), + only(AfiSafi::IPV4_UNICAST) + ); + // Other capabilities do not change that. + assert_eq!( + FamilySet::offered_by(&open(vec![Capability::RouteRefresh])), + only(AfiSafi::IPV4_UNICAST) + ); + } + + #[test] + fn a_peer_that_lists_families_gets_exactly_those() { + assert_eq!( + FamilySet::offered_by(&open(vec![Capability::multiprotocol( + AfiSafi::IPV6_UNICAST + )])), + only(AfiSafi::IPV6_UNICAST), + "IPv4 unicast is not implied once a peer lists its families" + ); + // Capabilities may be spread over several optional parameters. + let mut spread = open(vec![Capability::multiprotocol(AfiSafi::IPV4_UNICAST)]); + spread + .options + .push(OpenOption::Capabilities(vec![Capability::multiprotocol( + AfiSafi::IPV6_UNICAST, + )])); + assert_eq!(FamilySet::offered_by(&spread), FamilySet::supported()); + // A family we do not support is still what the peer offered. It + // drops out when intersected with what we offer. + let evpn = AfiSafi::new(Afi::L2VPN, Safi::EVPN); + let offered = FamilySet::offered_by(&open(vec![ + Capability::multiprotocol(evpn), + Capability::multiprotocol(AfiSafi::IPV6_UNICAST), + ])); + assert_eq!(offered.to_string(), "IPv6 unicast, L2VPN EVPN"); + assert_eq!( + FamilySet::supported().intersection(&offered), + only(AfiSafi::IPV6_UNICAST) + ); + assert!( + FamilySet::supported() + .intersection(&FamilySet::offered_by(&open(vec![ + Capability::multiprotocol(evpn) + ]))) + .is_empty() + ); + } + + #[test] + fn sets_intersect_and_describe_themselves() { + let v6 = only(AfiSafi::IPV6_UNICAST); + assert_eq!(FamilySet::supported().intersection(&v6), v6); + assert!(only(AfiSafi::IPV4_UNICAST).intersection(&v6).is_empty()); + assert_eq!( + FamilySet::supported().to_string(), + "IPv4 unicast, IPv6 unicast" + ); + assert_eq!(FamilySet::default().to_string(), "none"); + assert!(v6.carries(&prefix("2001:db8::/32"))); + assert!(!v6.carries(&prefix("192.0.2.0/24"))); + // One capability each: code 1, length 4, AFI, reserved, SAFI. + assert_eq!( + FamilySet::supported().capabilities_wire(), + [1, 4, 0, 1, 0, 1, 1, 4, 0, 2, 0, 1] + ); + } + + fn mixed_update() -> UpdateMessage { + UpdateMessage { + withdrawn_routes: vec![prefix("198.51.100.0/24").into()], + path_attributes: vec![ + PathAttribute::Origin(OriginPathAttribute::IGP), + PathAttribute::MpReachNlri(MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6(Ipv6Addr::LOCALHOST), + prefixes: vec![ + prefix("2001:db8:1::/48").into(), + prefix("2001:db8:2::/48").into(), + ], + }), + PathAttribute::MpUnreachNlri(MpUnreachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + prefixes: vec![prefix("2001:db8:3::/48").into()], + }), + ], + announced_routes: vec![prefix("192.0.2.0/24").into()], + } + } + + #[test] + fn restrict_removes_the_routes_of_families_not_in_the_set() { + let mut update = mixed_update(); + assert_eq!(FamilySet::supported().restrict(&mut update), 0); + assert_eq!(update, mixed_update()); + + let mut update = mixed_update(); + assert_eq!(only(AfiSafi::IPV4_UNICAST).restrict(&mut update), 3); + assert_eq!(update.announced_routes, vec![prefix("192.0.2.0/24")]); + assert_eq!(update.withdrawn_routes, vec![prefix("198.51.100.0/24")]); + assert_eq!( + update.path_attributes, + vec![PathAttribute::Origin(OriginPathAttribute::IGP)] + ); + + let mut update = mixed_update(); + assert_eq!(only(AfiSafi::IPV6_UNICAST).restrict(&mut update), 2); + assert!(update.announced_routes.is_empty()); + assert!(update.withdrawn_routes.is_empty()); + assert_eq!(update.path_attributes.len(), 3); + } +} diff --git a/crates/server/src/fsm.rs b/crates/server/src/fsm.rs index 7a9ba48..a616da8 100644 --- a/crates/server/src/fsm.rs +++ b/crates/server/src/fsm.rs @@ -1,389 +1,1034 @@ -use std::net::{Ipv4Addr, SocketAddr}; +//! The BGP finite state machine for one connection (RFC 4271 §8), without +//! any I/O. +//! +//! [`Fsm`] is told what happened as an [`Event`] and answers with the +//! [`Action`]s to carry out. It owns no socket, timer or task, so every +//! transition can be tested by calling [`Fsm::handle`]. The driver in +//! [`crate::connection`] turns bytes and timers into events and carries the +//! actions out. +//! +//! Only the states that have a TCP connection live here. Idle, Connect and +//! Active are the dialer's business ([`crate::dialer`]); a connection enters +//! this machine in OpenSent, with our OPEN as its first action. + +use std::net::Ipv4Addr; use std::time::Duration; -use bgp_packet::errors::{BgpError, FsmSubcode, OpenMsgSubcode}; -use bgp_packet::message::{BGP_HEADER_LEN, Message, NotificationMessage, PathAttribute}; -use bgp_packet::open::{Capability, OpenMessage, OpenOption}; -use bgp_primitives::constants::{AddressFamilyId, SubsequentAfi}; -use bgp_primitives::parser::ParserContext; -use bgp_rib::{AdjRibIn, PeerId, RibEntry}; -use bytes::BytesMut; -use eyre::{Result, bail, eyre}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; -use tokio::time::{Instant, interval_at, sleep_until}; -use tracing::{info, warn}; +use bgp_packet::errors::{BgpError, DecodeError, OpenMsgSubcode}; +use bgp_packet::message::{AS_TRANS, BGP4_VERSION, Message, UpdateMessage}; +use bgp_packet::open::{AddPathEntry, AddPathSendRecv, Capability, OpenMessage, OpenOption}; -use bgp_packet::message::{AS_TRANS, BGP4_VERSION}; +use crate::error::SessionError; +use crate::family::FamilySet; +use crate::metrics::message_type_name; -/// Minimal per-session config extracted from the server config. -#[derive(Clone)] -pub struct SessionConfig { +/// How long to wait for the peer's OPEN, and for its first KEEPALIVE when +/// the negotiated hold time is zero. RFC 4271 §8.2.2 suggests four minutes. +pub const INITIAL_HOLD_TIME: Duration = Duration::from_secs(240); + +/// Where a connection is in the handshake. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + /// Our OPEN is sent; waiting for the peer's. + OpenSent, + /// OPENs are exchanged; waiting for the KEEPALIVE that confirms ours. + OpenConfirm, + /// Routes may be exchanged. + Established, + /// The session is over. Every further event is ignored. + Closed, +} + +impl std::fmt::Display for State { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::OpenSent => "OpenSent", + Self::OpenConfirm => "OpenConfirm", + Self::Established => "Established", + Self::Closed => "Closed", + }) + } +} + +/// What the machine needs to know about this end of the session. +#[derive(Debug, Clone)] +pub struct FsmConfig { pub router_id: Ipv4Addr, pub local_asn: u32, + /// Hold time we propose, in seconds. The session uses the smaller of + /// this and the peer's. pub hold_time_secs: u16, - pub adj_rib_in: AdjRibIn, + /// If set, the AS the peer must announce in its OPEN. + pub expected_asn: Option, + /// The address families to offer the peer. Must not be empty. + pub families: FamilySet, + /// The directions to offer ADD-PATH (RFC 7911) for, on each of + /// `families`; `None` for neither. + pub add_path: Option, } -/// Drive the BGP FSM for a single accepted TCP connection. -/// -/// States visited: OpenSent → OpenConfirm → Established. -pub async fn run_session( - stream: TcpStream, - peer_addr: SocketAddr, - config: SessionConfig, -) -> Result<()> { - let (mut reader, mut writer) = stream.into_split(); - - // --- OpenSent ----------------------------------------------------------- - // Send our OPEN immediately after the TCP connection is established. - let our_open = build_open(&config); - send_message(&mut writer, &our_open, &ParserContext::default()).await?; - - // RFC 4271 §8.1: use a large initial hold timer (4 minutes) before we - // know the negotiated value. - let peer_msg = recv_with_timeout( - &mut reader, - &ParserContext::default(), - Duration::from_secs(240), - ) - .await; - - let peer_open = match peer_msg { - Ok(Message::Open(o)) => o, - Ok(other) => { - send_notification( - &mut writer, - BgpError::FsmError(FsmSubcode::UnexpectedOpenSent), - &ParserContext::default(), - ) - .await; - bail!("Expected OPEN in OpenSent state, got {:?}", other); - } - Err(e) => { - send_notification( - &mut writer, - BgpError::FsmError(FsmSubcode::UnexpectedOpenSent), - &ParserContext::default(), - ) - .await; - return Err(e); - } - }; - - if let Err(bgp_err) = validate_open(&peer_open) { - send_notification(&mut writer, bgp_err, &ParserContext::default()).await; - bail!("Peer {} sent invalid OPEN", peer_addr); - } - - let negotiated_hold_time = config.hold_time_secs.min(peer_open.hold_time); - let peer_four_byte_asn = peer_open.options.iter().any(|o| { - matches!(o, OpenOption::Capabilities(caps) - if caps.iter().any(|c| matches!(c, Capability::FourByteAsn { .. }))) - }); - let ctx = ParserContext { - four_octet_asn: Some(peer_four_byte_asn), - address_family: None, - }; - - let peer_asn = if peer_four_byte_asn { - peer_open - .options - .iter() - .find_map(|o| match o { - OpenOption::Capabilities(caps) => caps.iter().find_map(|c| match c { - Capability::FourByteAsn { asn } => Some(*asn), - _ => None, - }), - }) - .unwrap_or(peer_open.asn as u32) - } else { - peer_open.asn as u32 - }; - - info!( - %peer_addr, - peer_asn, - negotiated_hold_time, - "OPEN received, transitioning to OpenConfirm" - ); - - // --- OpenConfirm -------------------------------------------------------- - send_message(&mut writer, &Message::KeepAlive, &ctx).await?; - - let hold_duration = Duration::from_secs(negotiated_hold_time as u64); - - match recv_with_timeout(&mut reader, &ctx, hold_duration).await { - Ok(Message::KeepAlive) => {} - Ok(other) => { - send_notification( - &mut writer, - BgpError::FsmError(FsmSubcode::UnexpectedOpenConfirm), - &ctx, - ) - .await; - bail!("Expected KEEPALIVE in OpenConfirm state, got {:?}", other); - } - Err(e) => { - send_notification(&mut writer, BgpError::HoldTimer, &ctx).await; - return Err(e); - } - } - - info!(%peer_addr, peer_asn, "Session established"); - - let peer_id = PeerId { addr: peer_addr, asn: peer_asn }; - - // --- Established -------------------------------------------------------- - established_loop( - &mut reader, - &mut writer, - &ctx, - negotiated_hold_time, - peer_id, - &config.adj_rib_in, - ) - .await +/// What the two OPENs settled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Negotiated { + pub peer_asn: u32, + /// Whether the peer is in our own AS. + pub ibgp: bool, + pub peer_identifier: Ipv4Addr, + /// Zero means the hold and keepalive timers do not run (RFC 4271 §4.2). + pub hold_time_secs: u16, + /// Whether AS numbers are four octets wide on this session (RFC 6793). + pub four_octet_asn: bool, + /// The address families both sides offered: the ones routes may be + /// exchanged for. Never empty. + pub families: FamilySet, + /// The families for which the peer sends us several routes for a + /// prefix, each with a path identifier (ADD-PATH, RFC 7911): we offered + /// to receive them and the peer offered to send them. + pub add_path_receive: FamilySet, + /// The families for which we send the peer path identifiers: we + /// offered to send and the peer to receive. + pub add_path_send: FamilySet, } -async fn established_loop( - reader: &mut OwnedReadHalf, - writer: &mut OwnedWriteHalf, - ctx: &ParserContext, - hold_time_secs: u16, - peer_id: PeerId, - adj_rib_in: &AdjRibIn, -) -> Result<()> { - // When hold_time is 0, keepalives and the hold timer are disabled (RFC 4271 §4.4). - if hold_time_secs == 0 { - return no_timer_loop(reader, writer, ctx, peer_id, adj_rib_in).await; +/// A message from the peer, with what the decoder found out besides. +#[derive(Debug)] +pub struct Received { + pub message: Message, + /// For an UPDATE: its attributes as they were on the wire, if the + /// decoder kept them (`DecodedUpdate::attributes_wire`). They go with + /// the UPDATE to whoever stores its routes. + pub attributes_wire: Option>, +} + +impl From for Received { + fn from(message: Message) -> Self { + Self { + message, + attributes_wire: None, + } + } +} + +/// Something that happened to the connection. +#[derive(Debug)] +pub enum Event { + /// The peer sent a message. + Message(Received), + /// The peer sent something that is not a valid message. + DecodeFailed(DecodeError), + HoldTimerExpired, + KeepaliveTimerExpired, + /// Another connection to the same peer won collision resolution + /// (RFC 4271 §6.8), either when this one asked to be admitted or later. + CollisionLost, + /// The rest of the server wants this message sent to the peer. + Outbound(Message), + /// Something on our side keeps the session from going on: the routing + /// table has no room for the peer's routes, say. The peer is told as + /// the error has it. + LocalFailure(SessionError), +} + +/// Something the driver must do, in the order given. +#[derive(Debug)] +pub enum Action { + Send(Message), + /// Start the hold timer, or restart it if it is running. + ResetHoldTimer(Duration), + /// The hold timer does not run from here on. + StopHoldTimer, + /// From now on deliver [`Event::KeepaliveTimerExpired`] at this interval. + StartKeepaliveTimer(Duration), + /// The peer's OPEN is acceptable. Ask collision detection whether this + /// connection may go on. If it may not, drop the actions that follow + /// and deliver [`Event::CollisionLost`] instead. + Admit(Negotiated), + /// The session reached Established. + Established(Negotiated), + /// The peer sent this many routes of address families that were not + /// negotiated. They have been left out of the update that follows. + RoutesIgnored(usize), + /// Routes arrived; put them in the routing table. + DeliverUpdate { + update: UpdateMessage, + /// See [`Received::attributes_wire`]. + attributes_wire: Option>, + }, + /// The peer asked for everything we advertise to be sent again. + RouteRefreshRequested, + /// The session is over, for this reason. Always the last action. + Close(Result<(), SessionError>), +} + +/// [`State`] together with what is known in it. What the OPENs settled +/// exists exactly when the handshake has got that far, so it lives in the +/// states that have it and nowhere else. +#[derive(Debug, Clone)] +enum Phase { + OpenSent, + OpenConfirm(Negotiated), + Established(Negotiated), + Closed, +} + +/// The state machine for one connection. +#[derive(Debug)] +pub struct Fsm { + config: FsmConfig, + phase: Phase, +} + +impl Fsm { + /// A machine for a connection that has just been opened, and what to do + /// first: send our OPEN and wait for the peer's. + pub fn new(config: FsmConfig) -> (Self, Vec) { + let first = vec![ + Action::Send(build_open(&config)), + Action::ResetHoldTimer(INITIAL_HOLD_TIME), + ]; + let fsm = Self { + config, + phase: Phase::OpenSent, + }; + (fsm, first) } - let hold_duration = Duration::from_secs(hold_time_secs as u64); - let keepalive_interval = Duration::from_secs(hold_time_secs as u64 / 3); + pub fn state(&self) -> State { + match self.phase { + Phase::OpenSent => State::OpenSent, + Phase::OpenConfirm(_) => State::OpenConfirm, + Phase::Established(_) => State::Established, + Phase::Closed => State::Closed, + } + } - // interval_at avoids firing immediately; first keepalive is sent after one interval. - let mut keepalive_timer = interval_at(Instant::now() + keepalive_interval, keepalive_interval); + /// What the OPENs settled; `None` until the peer's OPEN is accepted. + #[cfg(test)] + fn negotiated(&self) -> Option<&Negotiated> { + match &self.phase { + Phase::OpenConfirm(negotiated) | Phase::Established(negotiated) => Some(negotiated), + Phase::OpenSent | Phase::Closed => None, + } + } - // sleep_until must be pinned so we can call .as_mut().reset() inside select!. - let hold_timer = sleep_until(Instant::now() + hold_duration); - tokio::pin!(hold_timer); + /// Advances the machine by one event. + pub fn handle(&mut self, event: Event) -> Vec { + if matches!(self.phase, Phase::Closed) { + return vec![]; + } + match event { + Event::Message(received) => self.on_message(received), + Event::DecodeFailed(e) => self.fail(SessionError::Decode(e)), + Event::CollisionLost => self.fail(SessionError::Collision), + Event::LocalFailure(e) => self.fail(e), + Event::HoldTimerExpired => self.fail(match self.phase { + Phase::Established(_) => SessionError::HoldTimerExpired, + _ => SessionError::Timeout(self.state()), + }), + Event::KeepaliveTimerExpired => match self.phase { + Phase::Established(_) => vec![Action::Send(Message::KeepAlive)], + _ => vec![], + }, + Event::Outbound(msg) => self.on_outbound(msg), + } + } - loop { - tokio::select! { - result = read_message(reader, ctx) => { - match result? { - Message::KeepAlive => { - hold_timer.as_mut().reset(Instant::now() + hold_duration); + fn on_message(&mut self, received: Received) -> Vec { + let Received { + message, + attributes_wire, + } = received; + match (&self.phase, message) { + // A NOTIFICATION ends the session in any state, and is not + // answered (RFC 4271 §8.2.2). + (_, Message::Notification(notification)) => { + self.close(Err(SessionError::NotifiedByPeer(notification.error))) + } + (Phase::OpenSent, Message::Open(open)) => self.on_open(open), + (Phase::OpenConfirm(negotiated), Message::KeepAlive) => { + let negotiated = negotiated.clone(); + let hold = hold_time(&negotiated); + self.phase = Phase::Established(negotiated.clone()); + let mut actions = vec![Action::Established(negotiated)]; + match hold { + Some(hold) => { + actions.push(Action::ResetHoldTimer(hold)); + actions.push(Action::StartKeepaliveTimer(hold / 3)); } - Message::Update(update) => { - info!( - peer = %peer_id, - withdrawn = update.withdrawn_routes.len(), - announced = update.announced_routes.len(), - "Received UPDATE", - ); - apply_update(adj_rib_in, peer_id, &update.path_attributes, - &update.withdrawn_routes, &update.announced_routes).await; - } - Message::Notification(notif) => { - info!(peer = %peer_id, error = %notif.error, "Received NOTIFICATION, closing session"); - return Ok(()); - } - Message::Open(_) => { - send_notification(writer, BgpError::FsmError(FsmSubcode::UnexpectedEstablished), ctx).await; - bail!("Received OPEN in Established state from {}", peer_id); - } - Message::RouteRefresh => {} + None => actions.push(Action::StopHoldTimer), + } + actions + } + (Phase::Established(negotiated), Message::KeepAlive) => heard_from_peer(negotiated), + (Phase::Established(negotiated), Message::Update(mut update)) => { + let mut actions = heard_from_peer(negotiated); + // A route of a family the session does not carry is ignored + // rather than treated as an error: it harms nothing, and + // resetting the session over it would. + let ignored = negotiated.families.restrict(&mut update); + if ignored > 0 { + actions.push(Action::RoutesIgnored(ignored)); + } + actions.push(Action::DeliverUpdate { + update, + attributes_wire, + }); + actions + } + (Phase::Established(negotiated), Message::RouteRefresh(_)) => { + let mut actions = heard_from_peer(negotiated); + actions.push(Action::RouteRefreshRequested); + actions + } + (_, other) => self.fail(SessionError::UnexpectedMessage { + state: self.state(), + received: message_type_name(&other), + }), + } + } + + fn on_open(&mut self, open: OpenMessage) -> Vec { + if let Err(refusal) = validate_open(&open, self.config.router_id) { + return self.fail(SessionError::OpenRefused(refusal)); + } + // A four-octet AS number does not fit the OPEN's own field; a peer + // that has one puts AS_TRANS there and the number in a capability. + let capability_asn = open.capabilities().find_map(|capability| match capability { + Capability::FourByteAsn { asn } => Some(*asn), + _ => None, + }); + let peer_asn = capability_asn.unwrap_or(u32::from(open.asn)); + if let Some(expected) = self.config.expected_asn + && peer_asn != expected + { + return self.fail(SessionError::WrongPeerAs { + expected, + actual: peer_asn, + }); + } + + let ours = self.config.families.clone(); + let theirs = FamilySet::offered_by(&open); + let families = ours.intersection(&theirs); + if families.is_empty() { + return self.fail(SessionError::NoCommonAddressFamily { ours, theirs }); + } + + // ADD-PATH is in use in a direction, for a family the session + // carries, when one side offered to send and the other to receive + // (RFC 7911 §4). + let (mut add_path_receive, mut add_path_send) = + (FamilySet::default(), FamilySet::default()); + if let Some(ours) = self.config.add_path { + let theirs = open.capabilities().flat_map(|capability| match capability { + Capability::AddPath { entries } => entries.as_slice(), + _ => &[], + }); + for entry in theirs.filter(|entry| families.contains(entry.family())) { + if ours.receives() && entry.send_recv.sends() { + add_path_receive.insert(entry.family()); + } + if ours.sends() && entry.send_recv.receives() { + add_path_send.insert(entry.family()); } } - _ = keepalive_timer.tick() => { - send_message(writer, &Message::KeepAlive, ctx).await?; - } - _ = &mut hold_timer => { - warn!(peer = %peer_id, "Hold timer expired"); - send_notification(writer, BgpError::HoldTimer, ctx).await; - bail!("Hold timer expired for peer {}", peer_id); - } } - } -} -/// Hold-timer-disabled path for hold_time == 0. -async fn no_timer_loop( - reader: &mut OwnedReadHalf, - writer: &mut OwnedWriteHalf, - ctx: &ParserContext, - peer_id: PeerId, - adj_rib_in: &AdjRibIn, -) -> Result<()> { - loop { - match read_message(reader, ctx).await? { - Message::KeepAlive => {} - Message::Update(update) => { - info!( - peer = %peer_id, - withdrawn = update.withdrawn_routes.len(), - announced = update.announced_routes.len(), - "Received UPDATE", - ); - apply_update(adj_rib_in, peer_id, &update.path_attributes, - &update.withdrawn_routes, &update.announced_routes).await; - } - Message::Notification(notif) => { - info!(peer = %peer_id, error = %notif.error, "Received NOTIFICATION, closing session"); - return Ok(()); - } - Message::Open(_) => { - send_notification( - writer, - BgpError::FsmError(FsmSubcode::UnexpectedEstablished), - ctx, - ) - .await; - bail!("Received OPEN in Established state from {}", peer_id); - } - Message::RouteRefresh => {} + let negotiated = Negotiated { + peer_asn, + ibgp: peer_asn == self.config.local_asn, + peer_identifier: open.identifier, + hold_time_secs: self.config.hold_time_secs.min(open.hold_time), + four_octet_asn: capability_asn.is_some(), + families, + add_path_receive, + add_path_send, + }; + // With a hold time of zero the timer will not run once Established, + // but a peer that never confirms must still be given up on. + let confirm_within = hold_time(&negotiated).unwrap_or(INITIAL_HOLD_TIME); + self.phase = Phase::OpenConfirm(negotiated.clone()); + vec![ + Action::Admit(negotiated), + Action::Send(Message::KeepAlive), + Action::ResetHoldTimer(confirm_within), + ] + } + + fn on_outbound(&mut self, msg: Message) -> Vec { + if !matches!(self.phase, Phase::Established(_)) { + // Nothing may be sent before Established but the handshake. + return vec![]; } - } -} - -// --------------------------------------------------------------------------- -// Wire helpers -// --------------------------------------------------------------------------- - -async fn read_message(reader: &mut OwnedReadHalf, ctx: &ParserContext) -> Result { - let mut header = [0u8; BGP_HEADER_LEN as usize]; - reader.read_exact(&mut header).await?; - - let total_len = u16::from_be_bytes([header[16], header[17]]) as usize; - if total_len < BGP_HEADER_LEN as usize { - bail!("BGP message length {total_len} below minimum"); - } - - let mut buf = vec![0u8; total_len]; - buf[..BGP_HEADER_LEN as usize].copy_from_slice(&header); - reader - .read_exact(&mut buf[BGP_HEADER_LEN as usize..]) - .await?; - - let (_, msg) = - Message::from_wire(ctx, &buf).map_err(|e| eyre!("Failed to parse BGP message: {e:?}"))?; - Ok(msg) -} - -async fn recv_with_timeout( - reader: &mut OwnedReadHalf, - ctx: &ParserContext, - timeout: Duration, -) -> Result { - tokio::time::timeout(timeout, read_message(reader, ctx)) - .await - .map_err(|_| eyre!("Timed out waiting for BGP message"))? -} - -async fn send_message( - writer: &mut OwnedWriteHalf, - msg: &Message, - ctx: &ParserContext, -) -> Result<()> { - let mut buf = BytesMut::new(); - msg.to_wire(ctx, &mut buf)?; - writer.write_all(&buf).await?; - Ok(()) -} - -/// Best-effort NOTIFICATION send — errors are logged and swallowed since we -/// are already tearing down the session. -async fn send_notification(writer: &mut OwnedWriteHalf, error: BgpError, ctx: &ParserContext) { - let msg = Message::Notification(NotificationMessage { - error, - data: vec![], - }); - if let Err(e) = send_message(writer, &msg, ctx).await { - warn!("Failed to send NOTIFICATION: {e}"); - } -} - -// --------------------------------------------------------------------------- -// RIB helpers -// --------------------------------------------------------------------------- - -/// Applies a received UPDATE to the Adj-RIB-In. -/// -/// Handles both the legacy IPv4 NLRI fields and the MP_REACH/MP_UNREACH path -/// attributes used for all other address families (RFC 2858). -async fn apply_update( - rib: &AdjRibIn, - peer: PeerId, - path_attributes: &[PathAttribute], - withdrawn: &[bgp_primitives::ip_prefix::IpPrefix], - announced: &[bgp_primitives::ip_prefix::IpPrefix], -) { - // Legacy IPv4 fields in the UPDATE body. - for prefix in withdrawn { - rib.withdraw(peer, prefix.clone()).await; - } - for prefix in announced { - rib.announce(peer, prefix.clone(), RibEntry::new(path_attributes.to_vec())).await; - } - - // Multiprotocol attributes carry NLRI for all other AFIs. - for attr in path_attributes { - match attr { - PathAttribute::MpReachNlri(mp) => { - for prefix in &mp.prefixes { - rib.announce(peer, prefix.clone(), RibEntry::new(path_attributes.to_vec())).await; - } - } - PathAttribute::MpUnreachNlri(mp) => { - for prefix in &mp.prefixes { - rib.withdraw(peer, prefix.clone()).await; - } - } - _ => {} + // A NOTIFICATION from our side ends the session (RFC 4271 §6). + let ends_session = matches!(msg, Message::Notification(_)); + let mut actions = vec![Action::Send(msg)]; + if ends_session { + actions.extend(self.close(Ok(()))); } + actions + } + + /// Ends the session because of `error`, telling the peer with the + /// NOTIFICATION that goes with it if there is one. + fn fail(&mut self, error: SessionError) -> Vec { + let mut actions: Vec = error + .notification() + .map(|notification| Action::Send(Message::Notification(notification))) + .into_iter() + .collect(); + actions.extend(self.close(Err(error))); + actions + } + + fn close(&mut self, result: Result<(), SessionError>) -> Vec { + self.phase = Phase::Closed; + vec![Action::Close(result)] } } -// --------------------------------------------------------------------------- -// OPEN construction and validation -// --------------------------------------------------------------------------- +/// Any message from the peer shows it is alive (RFC 4271 §4.4). +fn heard_from_peer(negotiated: &Negotiated) -> Vec { + hold_time(negotiated) + .map(Action::ResetHoldTimer) + .into_iter() + .collect() +} -fn build_open(config: &SessionConfig) -> Message { +/// The hold time as a duration; `None` when the timers do not run. +fn hold_time(negotiated: &Negotiated) -> Option { + match negotiated.hold_time_secs { + 0 => None, + secs => Some(Duration::from_secs(secs.into())), + } +} + +fn build_open(config: &FsmConfig) -> Message { // Use AS_TRANS in the 2-byte field when the ASN doesn't fit; the real ASN // is carried in the FourByteAsn capability (RFC 6793). - let asn_field = if config.local_asn > u16::MAX as u32 { - AS_TRANS - } else { - config.local_asn as u16 - }; - + let asn_field = u16::try_from(config.local_asn).unwrap_or(AS_TRANS); + let mut capabilities = vec![Capability::FourByteAsn { + asn: config.local_asn, + }]; + // One Multiprotocol capability per family we will exchange. A peer does + // not accept routes of a family that was not offered (RFC 4760 §8). + capabilities.extend(config.families.iter().map(Capability::multiprotocol)); + capabilities.push(Capability::RouteRefresh); + if let Some(send_recv) = config.add_path { + capabilities.push(Capability::AddPath { + entries: config + .families + .iter() + .map(|family| AddPathEntry { + afi: family.afi, + safi: family.safi, + send_recv, + }) + .collect(), + }); + } Message::Open(OpenMessage { version: BGP4_VERSION, asn: asn_field, hold_time: config.hold_time_secs, identifier: config.router_id, - options: vec![OpenOption::Capabilities(vec![ - Capability::FourByteAsn { - asn: config.local_asn, - }, - Capability::MultiProtocol { - afi: AddressFamilyId::Ipv4, - safi: SubsequentAfi::Unicast, - }, - Capability::RouteRefresh, - ])], + options: vec![OpenOption::Capabilities(capabilities)], }) } -fn validate_open(open: &OpenMessage) -> Result<(), BgpError> { +fn validate_open(open: &OpenMessage, local_id: Ipv4Addr) -> Result<(), BgpError> { if open.version != BGP4_VERSION { return Err(BgpError::OpenMsg(OpenMsgSubcode::UnsupportedVersion)); } + // A peer cannot share our identifier or use the unspecified address; + // collision resolution depends on identifiers being distinct. + if open.identifier == local_id || open.identifier.is_unspecified() { + return Err(BgpError::OpenMsg(OpenMsgSubcode::BadBgpId)); + } if open.hold_time != 0 && open.hold_time < 3 { return Err(BgpError::OpenMsg(OpenMsgSubcode::UnacceptableHoldTime)); } Ok(()) } + +#[cfg(test)] +mod tests { + use bgp_packet::errors::{CeaseSubcode, FsmSubcode, MsgHeaderSubcode}; + use bgp_packet::message::{ + MpReachNlriPathAttribute, NlriNextHop, NotificationMessage, PathAttribute, + RouteRefreshMessage, + }; + use bgp_primitives::constants::AfiSafi; + use bgp_primitives::ip_prefix::IpPrefix; + + use bgp_rib::table::TooManyPeers; + + use super::*; + use crate::family::SUPPORTED; + + const LOCAL_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const PEER_ID: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); + + fn config() -> FsmConfig { + FsmConfig { + router_id: LOCAL_ID, + local_asn: 65000, + hold_time_secs: 90, + expected_asn: None, + families: FamilySet::supported(), + add_path: None, + } + } + + fn only(family: AfiSafi) -> FamilySet { + [family].into_iter().collect() + } + + fn capabilities(open: &Message) -> Vec<&Capability> { + match open { + Message::Open(open) => open.capabilities().collect(), + other => panic!("expected OPEN, got {other:?}"), + } + } + + fn peer_open(hold_time: u16) -> OpenMessage { + OpenMessage { + version: BGP4_VERSION, + asn: 65001, + hold_time, + identifier: PEER_ID, + options: vec![], + } + } + + /// A machine taken as far as `state` by a well-behaved peer. + fn fsm_in(state: State, peer_hold_time: u16) -> Fsm { + let (mut fsm, _) = Fsm::new(config()); + if state != State::OpenSent { + fsm.handle(received(Message::Open(peer_open(peer_hold_time)))); + } + if state == State::Established { + fsm.handle(received(Message::KeepAlive)); + } + assert_eq!(fsm.state(), state); + fsm + } + + fn received(message: Message) -> Event { + Event::Message(message.into()) + } + + fn empty_update() -> UpdateMessage { + UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![], + announced_routes: vec![], + } + } + + /// The NOTIFICATION sent and the reason given, for a machine that ended + /// the session. + fn ending(fsm: &Fsm, actions: Vec) -> (Option, Result<(), SessionError>) { + assert_eq!(fsm.state(), State::Closed); + let mut sent = None; + let mut result = None; + let count = actions.len(); + for (i, action) in actions.into_iter().enumerate() { + match action { + Action::Send(Message::Notification(n)) => sent = Some(n.error), + Action::Close(r) => { + assert_eq!(i, count - 1, "Close is the last action"); + result = Some(r); + } + other => panic!("unexpected action while closing: {other:?}"), + } + } + (sent, result.expect("a closing machine says why")) + } + + #[test] + fn a_new_connection_sends_open_and_waits_four_minutes_for_the_peers() { + let (fsm, actions) = Fsm::new(config()); + assert_eq!(fsm.state(), State::OpenSent); + assert!(matches!( + &actions[..], + [ + Action::Send(Message::Open(open)), + Action::ResetHoldTimer(INITIAL_HOLD_TIME) + ] if open.identifier == LOCAL_ID && open.hold_time == 90 + )); + } + + #[test] + fn the_handshake_reaches_established_and_starts_the_timers() { + let (mut fsm, _) = Fsm::new(config()); + + // The peer proposes 30 seconds, we 90: the smaller one holds. + let actions = fsm.handle(received(Message::Open(peer_open(30)))); + assert_eq!(fsm.state(), State::OpenConfirm); + let expected = Negotiated { + peer_asn: 65001, + ibgp: false, + peer_identifier: PEER_ID, + hold_time_secs: 30, + four_octet_asn: false, + // The peer listed no families, so it speaks IPv4 unicast. + families: only(AfiSafi::IPV4_UNICAST), + add_path_receive: FamilySet::default(), + add_path_send: FamilySet::default(), + }; + assert!(matches!( + &actions[..], + [ + Action::Admit(n), + Action::Send(Message::KeepAlive), + Action::ResetHoldTimer(hold) + ] if *n == expected && *hold == Duration::from_secs(30) + )); + + let actions = fsm.handle(received(Message::KeepAlive)); + assert_eq!(fsm.state(), State::Established); + assert!(matches!( + &actions[..], + [ + Action::Established(n), + Action::ResetHoldTimer(hold), + Action::StartKeepaliveTimer(keepalive) + ] if *n == expected + && *hold == Duration::from_secs(30) + && *keepalive == Duration::from_secs(10) + )); + } + + #[test] + fn a_hold_time_of_zero_runs_no_timers_once_established() { + let (mut fsm, _) = Fsm::new(config()); + let actions = fsm.handle(received(Message::Open(peer_open(0)))); + // The KEEPALIVE that confirms the session is still waited for. + assert!(matches!( + actions.last(), + Some(Action::ResetHoldTimer(INITIAL_HOLD_TIME)) + )); + + let actions = fsm.handle(received(Message::KeepAlive)); + assert!(matches!( + &actions[..], + [Action::Established(_), Action::StopHoldTimer] + )); + // And hearing from the peer restarts nothing. + assert!(fsm.handle(received(Message::KeepAlive)).is_empty()); + } + + #[test] + fn a_four_octet_as_number_comes_from_the_capability() { + let (mut fsm, _) = Fsm::new(FsmConfig { + expected_asn: Some(210036), + ..config() + }); + let open = OpenMessage { + asn: AS_TRANS, + options: vec![OpenOption::Capabilities(vec![Capability::FourByteAsn { + asn: 210036, + }])], + ..peer_open(90) + }; + fsm.handle(received(Message::Open(open))); + let negotiated = fsm.negotiated().unwrap(); + assert_eq!(negotiated.peer_asn, 210036); + assert!(negotiated.four_octet_asn); + } + + #[test] + fn our_open_offers_exactly_the_configured_families() { + let (_, actions) = Fsm::new(FsmConfig { + families: only(AfiSafi::IPV6_UNICAST), + ..config() + }); + let Action::Send(open) = &actions[0] else { + panic!("expected OPEN first, got {actions:?}") + }; + assert_eq!( + capabilities(open), + [ + &Capability::FourByteAsn { asn: 65000 }, + &Capability::multiprotocol(AfiSafi::IPV6_UNICAST), + &Capability::RouteRefresh, + ] + ); + } + + #[test] + fn the_session_carries_the_families_both_sides_offered() { + let negotiated = |ours, theirs: Vec| { + let (mut fsm, _) = Fsm::new(FsmConfig { + families: ours, + ..config() + }); + let open = OpenMessage { + options: vec![OpenOption::Capabilities( + theirs.into_iter().map(Capability::multiprotocol).collect(), + )], + ..peer_open(90) + }; + let actions = fsm.handle(received(Message::Open(open))); + (fsm, actions) + }; + + let (fsm, _) = negotiated(FamilySet::supported(), vec![AfiSafi::IPV6_UNICAST]); + assert_eq!( + fsm.negotiated().unwrap().families, + only(AfiSafi::IPV6_UNICAST) + ); + let (fsm, _) = negotiated(only(AfiSafi::IPV4_UNICAST), SUPPORTED.to_vec()); + assert_eq!( + fsm.negotiated().unwrap().families, + only(AfiSafi::IPV4_UNICAST) + ); + + // Nothing in common: the session could carry no routes at all. The + // NOTIFICATION names the capability we needed. + let (fsm, actions) = negotiated(only(AfiSafi::IPV4_UNICAST), vec![AfiSafi::IPV6_UNICAST]); + assert!(matches!( + &actions[0], + Action::Send(Message::Notification(n)) + if n.error == BgpError::OpenMsg(OpenMsgSubcode::UnsupportedCapability) + && n.data == [1, 4, 0, 1, 0, 1] + )); + assert!(matches!( + ending(&fsm, actions).1, + Err(SessionError::NoCommonAddressFamily { .. }) + )); + } + + /// ADD-PATH is in use for a family and a direction only where one side + /// offered to send and the other to receive. + #[test] + fn add_path_is_in_use_where_one_side_sends_and_the_other_receives() { + use bgp_packet::open::SendReceive::{Both, Receive, Send}; + let negotiated = |ours: Option, + theirs: Vec<(AfiSafi, AddPathSendRecv)>| { + let (mut fsm, first) = Fsm::new(FsmConfig { + add_path: ours, + ..config() + }); + let mut capabilities: Vec = SUPPORTED + .into_iter() + .map(Capability::multiprotocol) + .collect(); + if !theirs.is_empty() { + capabilities.push(Capability::AddPath { + entries: theirs + .into_iter() + .map(|(family, send_recv)| AddPathEntry { + afi: family.afi, + safi: family.safi, + send_recv, + }) + .collect(), + }); + } + let open = OpenMessage { + options: vec![OpenOption::Capabilities(capabilities)], + ..peer_open(90) + }; + fsm.handle(received(Message::Open(open))); + let n = fsm.negotiated().unwrap().clone(); + (first, n.add_path_receive, n.add_path_send) + }; + let (v4, v6) = (AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST); + let none = FamilySet::default; + + // We offer it for every family we offer, in the directions asked. + let (first, receive, send) = negotiated(Some(Receive), vec![(v4, Both), (v6, Receive)]); + let Action::Send(open) = &first[0] else { + panic!("expected OPEN first") + }; + assert!( + capabilities(open).contains(&&Capability::AddPath { + entries: SUPPORTED + .into_iter() + .map(|family| AddPathEntry { + afi: family.afi, + safi: family.safi, + send_recv: Receive, + }) + .collect(), + }) + ); + // The peer sends for IPv4 only; we did not offer to send at all. + assert_eq!((receive, send), (only(v4), none())); + + let (_, receive, send) = negotiated(Some(Both), vec![(v4, Send), (v6, Receive)]); + assert_eq!((receive, send), (only(v4), only(v6))); + + // Not offered by us: not in use, whatever the peer offers, and not + // in our OPEN. + let (first, receive, send) = negotiated(None, vec![(v4, Both)]); + let Action::Send(open) = &first[0] else { + panic!("expected OPEN first") + }; + assert!( + !capabilities(open) + .iter() + .any(|c| matches!(c, Capability::AddPath { .. })) + ); + assert_eq!((receive, send), (none(), none())); + + // Not offered by the peer: not in use either. + let (_, receive, send) = negotiated(Some(Both), vec![]); + assert_eq!((receive, send), (none(), none())); + } + + #[test] + fn routes_of_a_family_that_was_not_negotiated_are_ignored() { + // The peer lists no families, so only IPv4 unicast is in use. + let mut fsm = fsm_in(State::Established, 90); + let v4 = IpPrefix::try_from("192.0.2.0/24").unwrap(); + let update = UpdateMessage { + withdrawn_routes: vec![], + path_attributes: vec![PathAttribute::MpReachNlri(MpReachNlriPathAttribute { + family: AfiSafi::IPV6_UNICAST, + next_hop: NlriNextHop::Ipv6(std::net::Ipv6Addr::LOCALHOST), + prefixes: vec![IpPrefix::try_from("2001:db8::/32").unwrap().into()], + })], + announced_routes: vec![v4.into()], + }; + + let actions = fsm.handle(received(Message::Update(update))); + assert_eq!(fsm.state(), State::Established, "the session survives"); + assert!(matches!( + &actions[..], + [ + Action::ResetHoldTimer(_), + Action::RoutesIgnored(1), + Action::DeliverUpdate { update: delivered, .. } + ] if delivered.announced_routes == [v4] && delivered.path_attributes.is_empty() + )); + } + + #[test] + fn an_unacceptable_open_is_refused_with_the_matching_subcode() { + let refused = |open: OpenMessage, config: FsmConfig| { + let (mut fsm, _) = Fsm::new(config); + let actions = fsm.handle(received(Message::Open(open))); + ending(&fsm, actions).0 + }; + let open_error = |subcode| Some(BgpError::OpenMsg(subcode)); + + assert_eq!( + refused( + OpenMessage { + version: 3, + ..peer_open(90) + }, + config() + ), + open_error(OpenMsgSubcode::UnsupportedVersion) + ); + assert_eq!( + refused( + OpenMessage { + identifier: LOCAL_ID, + ..peer_open(90) + }, + config() + ), + open_error(OpenMsgSubcode::BadBgpId), + "a peer cannot have our identifier" + ); + assert_eq!( + refused(peer_open(2), config()), + open_error(OpenMsgSubcode::UnacceptableHoldTime) + ); + assert_eq!( + refused( + peer_open(90), + FsmConfig { + expected_asn: Some(64999), + ..config() + } + ), + open_error(OpenMsgSubcode::BadPeerAs) + ); + } + + #[test] + fn a_message_out_of_turn_is_an_fsm_error_for_the_state_it_arrived_in() { + let cases = [ + ( + State::OpenSent, + Message::KeepAlive, + FsmSubcode::UnexpectedOpenSent, + ), + ( + State::OpenConfirm, + Message::Update(empty_update()), + FsmSubcode::UnexpectedOpenConfirm, + ), + ( + State::Established, + Message::Open(peer_open(90)), + FsmSubcode::UnexpectedEstablished, + ), + ]; + for (state, msg, subcode) in cases { + let mut fsm = fsm_in(state, 90); + let actions = fsm.handle(received(msg)); + let (sent, result) = ending(&fsm, actions); + assert_eq!(sent, Some(BgpError::FsmError(subcode)), "{state}"); + assert!( + matches!(result, Err(SessionError::UnexpectedMessage { state: s, .. }) if s == state) + ); + } + } + + #[test] + fn silence_ends_the_session_with_hold_timer_expired_in_every_state() { + for state in [State::OpenSent, State::OpenConfirm, State::Established] { + let mut fsm = fsm_in(state, 90); + let actions = fsm.handle(Event::HoldTimerExpired); + let (sent, result) = ending(&fsm, actions); + assert_eq!(sent, Some(BgpError::HoldTimer), "{state}"); + match state { + State::Established => { + assert!(matches!(result, Err(SessionError::HoldTimerExpired))) + } + waiting => { + assert!(matches!(result, Err(SessionError::Timeout(s)) if s == waiting)) + } + } + } + } + + #[test] + fn losing_a_collision_sends_cease_whenever_it_happens() { + for state in [State::OpenConfirm, State::Established] { + let mut fsm = fsm_in(state, 90); + let actions = fsm.handle(Event::CollisionLost); + let (sent, result) = ending(&fsm, actions); + assert_eq!( + sent, + Some(BgpError::Cease(CeaseSubcode::ConnCollisionResolution)), + "{state}" + ); + assert!(matches!(result, Err(SessionError::Collision))); + } + } + + #[test] + fn undecodable_input_is_answered_with_the_notification_the_decoder_named() { + let mut fsm = fsm_in(State::Established, 90); + let actions = fsm.handle(Event::DecodeFailed(DecodeError { + notification: BgpError::MsgHeader(MsgHeaderSubcode::BadMessageType), + data: vec![9], + detail: "unknown BGP message type 9".into(), + })); + assert!(matches!( + &actions[0], + Action::Send(Message::Notification(n)) + if n.error == BgpError::MsgHeader(MsgHeaderSubcode::BadMessageType) && n.data == [9] + )); + assert!(matches!( + ending(&fsm, actions).1, + Err(SessionError::Decode(_)) + )); + } + + #[test] + fn a_notification_from_the_peer_ends_the_session_without_a_reply() { + for state in [State::OpenSent, State::OpenConfirm, State::Established] { + let mut fsm = fsm_in(state, 90); + let cease = BgpError::Cease(CeaseSubcode::AdminShutdown); + let actions = fsm.handle(received(Message::Notification(NotificationMessage { + error: cease.clone(), + data: vec![], + }))); + let (sent, result) = ending(&fsm, actions); + assert_eq!(sent, None, "{state}"); + assert!(matches!(result, Err(SessionError::NotifiedByPeer(e)) if e == cease)); + } + } + + #[test] + fn established_traffic_restarts_the_hold_timer_and_is_passed_on() { + let mut fsm = fsm_in(State::Established, 30); + let hold = Duration::from_secs(30); + + let actions = fsm.handle(received(Message::KeepAlive)); + assert!(matches!(&actions[..], [Action::ResetHoldTimer(d)] if *d == hold)); + + let actions = fsm.handle(received(Message::Update(empty_update()))); + assert!(matches!( + &actions[..], + [Action::ResetHoldTimer(d), Action::DeliverUpdate { .. }] if *d == hold + )); + + let actions = fsm.handle(received(Message::RouteRefresh( + RouteRefreshMessage::request(AfiSafi::IPV4_UNICAST), + ))); + assert!(matches!( + &actions[..], + [Action::ResetHoldTimer(d), Action::RouteRefreshRequested] if *d == hold + )); + + let actions = fsm.handle(Event::KeepaliveTimerExpired); + assert!(matches!(&actions[..], [Action::Send(Message::KeepAlive)])); + assert_eq!(fsm.state(), State::Established); + } + + #[test] + fn outbound_messages_are_sent_only_once_established() { + let mut waiting = fsm_in(State::OpenConfirm, 90); + assert!( + waiting + .handle(Event::Outbound(Message::Update(empty_update()))) + .is_empty() + ); + assert_eq!(waiting.state(), State::OpenConfirm); + + let mut fsm = fsm_in(State::Established, 90); + let actions = fsm.handle(Event::Outbound(Message::Update(empty_update()))); + assert!(matches!(&actions[..], [Action::Send(Message::Update(_))])); + + // Sending a NOTIFICATION is how the server shuts a session down. + let actions = fsm.handle(Event::Outbound(Message::Notification( + NotificationMessage { + error: BgpError::Cease(CeaseSubcode::AdminShutdown), + data: vec![], + }, + ))); + assert!(matches!( + &actions[..], + [ + Action::Send(Message::Notification(_)), + Action::Close(Ok(())) + ] + )); + assert_eq!(fsm.state(), State::Closed); + } + + #[test] + fn a_full_table_ends_the_session_and_says_why() { + let mut fsm = fsm_in(State::Established, 90); + let actions = fsm.handle(Event::LocalFailure(SessionError::TableFull(TooManyPeers))); + let (sent, result) = ending(&fsm, actions); + assert_eq!(sent, Some(BgpError::Cease(CeaseSubcode::OutOfResources))); + assert!(matches!(result, Err(SessionError::TableFull(_)))); + } + + #[test] + fn a_closed_machine_ignores_everything() { + let mut fsm = fsm_in(State::Established, 90); + fsm.handle(Event::HoldTimerExpired); + assert_eq!(fsm.state(), State::Closed); + for event in [ + received(Message::KeepAlive), + Event::HoldTimerExpired, + Event::KeepaliveTimerExpired, + Event::CollisionLost, + Event::Outbound(Message::KeepAlive), + ] { + assert!(fsm.handle(event).is_empty()); + } + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index e88955b..ca003cc 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -1,7 +1,17 @@ +pub mod advertise; +pub mod collision; pub mod config; pub mod connection; +pub mod dialer; +pub mod error; +pub mod family; pub mod fsm; +pub mod metrics; +mod panic; +mod routes; pub mod server; +pub mod session; +mod sync; pub mod tracing; pub mod proto { 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..d5566f9 --- /dev/null +++ b/crates/server/src/metrics.rs @@ -0,0 +1,133 @@ +//! 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 UPDATE_ERRORS: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "bgp_update_errors_total", + "Errors in received UPDATE messages that did not reset the session, by how they were handled (RFC 7606)", + &["handling"] + ) + .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 CONNECTIONS_REFUSED: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "bgp_connections_refused_total", + "Incoming connections closed without a session, by why", + &["reason"] + ) + .expect("metric registration") +}); + +pub static COLLISIONS: LazyLock = LazyLock::new(|| { + register_int_counter_vec!( + "bgp_connection_collisions_total", + "Connection collisions resolved, by which connection was closed", + &["closed"] + ) + .expect("metric registration") +}); + +pub static LOCAL_ROUTES: LazyLock = LazyLock::new(|| { + register_int_gauge!("bgp_local_routes", "Locally originated routes") + .expect("metric registration") +}); + +pub static RIB_ROUTES: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "bgp_rib_routes", + "Routes learned from peers, counting each peer's separately" + ) + .expect("metric registration") +}); + +pub static RIB_PREFIXES: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "bgp_rib_prefixes", + "Prefixes with at least one route, and so with a best route" + ) + .expect("metric registration") +}); + +pub static RIB_ATTRIBUTE_SETS: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "bgp_rib_attribute_sets", + "Distinct sets of path attributes held for the routes" + ) + .expect("metric registration") +}); + +pub static RIB_JOURNAL_ENTRIES: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "bgp_rib_journal_entries", + "Changes of best route waiting for a follower to read them" + ) + .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(&UPDATE_ERRORS); + LazyLock::force(&SESSIONS_ESTABLISHED_TOTAL); + LazyLock::force(&SESSIONS_ESTABLISHED); + LazyLock::force(&COLLISIONS); + LazyLock::force(&LOCAL_ROUTES); + LazyLock::force(&RIB_ROUTES); + LazyLock::force(&RIB_PREFIXES); + LazyLock::force(&RIB_ATTRIBUTE_SETS); + LazyLock::force(&RIB_JOURNAL_ENTRIES); +} + +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/panic.rs b/crates/server/src/panic.rs new file mode 100644 index 0000000..0adf54f --- /dev/null +++ b/crates/server/src/panic.rs @@ -0,0 +1,60 @@ +//! Containing panics. +//! +//! A panic is a bug. It unwinds the task it happens in, and what that task +//! was responsible for still has to be finished properly: a session tells +//! its peer it is closing and gives up its routes; the server, if it lost +//! something it cannot do without, closes every session and exits. + +use std::any::Any; +use std::panic::AssertUnwindSafe; + +use futures::FutureExt; + +/// Runs `future` to completion. If it panics, the panic is caught and its +/// message returned, so that the caller can clean up after it. +/// +/// The caller must not go on using what the future was working on as if +/// nothing had happened: a panic can leave it half changed. Closing it down +/// is what this is for. +pub(crate) async fn contained(future: F) -> Result { + AssertUnwindSafe(future) + .catch_unwind() + .await + .map_err(|payload| message(payload.as_ref())) +} + +/// The text of a panic, from what `catch_unwind` or a `JoinError` carries. +pub(crate) fn message(payload: &(dyn Any + Send)) -> String { + if let Some(text) = payload.downcast_ref::<&'static str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "a panic that carried no message".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_panic_is_caught_with_its_message() { + assert_eq!(contained(async { 7 }).await, Ok(7)); + + let literal: Result<(), _> = contained(async { panic!("the table is gone") }).await; + assert_eq!(literal, Err("the table is gone".to_string())); + + let formatted: Result<(), _> = + contained(async { panic!("index {} out of range", 9) }).await; + assert_eq!(formatted, Err("index 9 out of range".to_string())); + + // After a point where the future had been suspended, too. + let later: Result<(), _> = contained(async { + tokio::task::yield_now().await; + panic!("later") + }) + .await; + assert_eq!(later, Err("later".to_string())); + } +} diff --git a/crates/server/src/routes.rs b/crates/server/src/routes.rs new file mode 100644 index 0000000..606894e --- /dev/null +++ b/crates/server/src/routes.rs @@ -0,0 +1,64 @@ +//! Putting the routes of a received UPDATE into the routing table. + +use std::sync::Arc; + +use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage}; +use bgp_primitives::ip_prefix::Nlri; +use bgp_primitives::parser::EncodeError; +use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable}; + +/// Applies `update`, received from `peer`, to `table`: its withdrawals +/// first and then its announcements, so that a prefix named in both ends up +/// announced (RFC 4271 §4.3). +/// +/// An UPDATE names prefixes in four places: withdrawn routes and NLRI in the +/// message body, which are IPv4, and the MP_UNREACH_NLRI and MP_REACH_NLRI +/// attributes for every other family (RFC 4760). Where the peer sends +/// several routes for a prefix (ADD-PATH, RFC 7911), each is named by its +/// prefix and path identifier together, and is a route of its own. +/// +/// `attributes_wire` is the attributes as the decoder kept them, if it did +/// (see `DecodedUpdate::attributes_wire`). With it the table finds the +/// attribute set from those bytes, without writing the attributes out again. +pub(crate) fn apply( + table: &RibTable, + peer: PeerIndex, + update: &UpdateMessage, + attributes_wire: Option<&[u8]>, +) -> Result { + let mut outcome = Outcome::default(); + + if !update.withdrawn_routes.is_empty() { + outcome += table.withdraw(peer, &update.withdrawn_routes); + } + for attribute in &update.path_attributes { + if let PathAttribute::MpUnreachNlri(mp) = attribute { + outcome += table.withdraw(peer, &mp.prefixes); + } + } + + let attributes = |next_hop: Option<&NlriNextHop>| -> Result, EncodeError> { + if let Some(set) = + attributes_wire.and_then(|wire| table.attrs().intern_received(wire, next_hop)) + { + return Ok(set); + } + table.attrs().intern(&update.path_attributes, next_hop) + }; + let mut announce = |nlri: &[Nlri], next_hop| -> Result<(), EncodeError> { + if !nlri.is_empty() { + outcome += table.announce(peer, &attributes(next_hop)?, nlri); + } + Ok(()) + }; + // Routes in the body have their next hop in the NEXT_HOP attribute, + // which is one of the set's attributes; routes in MP_REACH_NLRI bring + // theirs along. + announce(&update.announced_routes, None)?; + for attribute in &update.path_attributes { + if let PathAttribute::MpReachNlri(mp) = attribute { + announce(&mp.prefixes, Some(&mp.next_hop))?; + } + } + Ok(outcome) +} diff --git a/crates/server/src/server.rs b/crates/server/src/server.rs index 69f791c..58b5284 100644 --- a/crates/server/src/server.rs +++ b/crates/server/src/server.rs @@ -1,70 +1,123 @@ +//! The server: listeners, the tasks that run for as long as it does, and +//! what happens when one of them stops. + +use std::collections::HashMap; use std::io; -use std::net::SocketAddr; -use std::sync::Arc; +use std::net::{IpAddr, SocketAddr}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; -use eyre::{Result, WrapErr}; use futures::stream::{FuturesUnordered, StreamExt}; -use tokio::net::{TcpListener, TcpStream}; -use tracing::{error, info}; +use tokio::net::{TcpListener, TcpSocket, TcpStream}; +use tokio::task::{JoinError, JoinHandle}; +use tracing::{error, info, warn}; -use bgp_rib::AdjRibIn; +use bgp_rib::table::RibTable; +use bgp_rib::{LocalRib, RibStopped}; +use crate::advertise::spawn_advertiser; +use crate::collision::{CollisionTable, Direction}; use crate::config::Config; -use crate::connection::handle_connection; -use crate::fsm::SessionConfig; +use crate::connection::{PeerSettings, ServerContext, run_session}; +use crate::dialer::spawn_dialers; +use crate::error::ServerError; +use crate::metrics::CONNECTIONS_REFUSED; +use crate::panic; +use crate::session::{ConfiguredPeer, SessionRegistry}; +use crate::sync::IgnorePoison; -pub struct BgpServerBuilder { - config: Config, -} +/// How long sessions get to send their Cease when the server stops. +const SHUTDOWN_GRACE: Duration = Duration::from_secs(2); -/// A `BgpServerBuilder` that has bound all its listeners and is ready to accept. -/// Call [`BgpServer::run`] to start the accept loop. +/// How long to wait before accepting again after an accept failed. What +/// makes it fail (no file descriptors left, usually) does not pass at once, +/// and trying again in a loop would only fill the log. +const ACCEPT_RETRY: Duration = Duration::from_millis(200); + +/// How often attribute sets that no route uses any more are dropped. They +/// are not dropped as their last route goes (see `AttrStore::sweep`), and a +/// table whose routes keep changing attributes collects them. +const SWEEP_INTERVAL: Duration = Duration::from_secs(60); + +/// How many connections may wait to be accepted on a listener. +const LISTEN_BACKLOG: u32 = 1024; + +/// How many connections one address may have open to us at a time. A peer +/// needs one, a second while a collision is resolved, and now and then one +/// more while an old one is being closed. Each connection costs a task and +/// a socket for up to four minutes before it has to show an OPEN, so +/// without a limit a single host could use up either. +const MAX_CONNECTIONS_PER_ADDRESS: usize = 4; + +/// At most one line in the log about refused connections in this long: a +/// host that keeps trying must not be able to fill the log. +const REFUSAL_LOG_INTERVAL: Duration = Duration::from_secs(10); + +/// A BGP speaker that has bound its listeners and is ready to accept. +/// Call [`BgpServer::run`] to start it. pub struct BgpServer { config: Config, listeners: Vec<(SocketAddr, TcpListener)>, - /// Shared RIB handle — created at bind time so callers can clone it before - /// the accept loop starts (e.g. tests that want to introspect the RIB). - pub adj_rib_in: AdjRibIn, + /// The routing table: every route learned from peers, and the best one + /// for each prefix. Created at bind time so that callers can share it + /// before the accept loop starts. + pub rib: Arc, + /// Routes this router originates; announced to every established peer. + pub local_rib: LocalRib, + /// Live view of established sessions. + pub registry: SessionRegistry, } -impl BgpServerBuilder { - pub fn new(config: Config) -> Self { - Self { config } - } - - /// Binds all configured listen endpoints and returns a [`BgpServer`]. - /// Separating bind from run lets callers (e.g. tests) inspect the actual - /// bound addresses before the accept loop starts. - pub async fn bind(self) -> Result { - let mut listeners = Vec::with_capacity(self.config.listen_endpoints.len()); - for &addr in &self.config.listen_endpoints { - let listener = TcpListener::bind(addr) - .await - .wrap_err_with(|| format!("Failed to bind listener on {addr}"))?; +impl BgpServer { + /// Binds all configured listen endpoints. Separate from + /// [`BgpServer::run`] so that callers (tests, say) can learn the + /// addresses actually bound before any connection is accepted. + pub async fn bind(config: Config) -> Result { + let mut listeners = Vec::with_capacity(config.listen_endpoints.len()); + for &addr in &config.listen_endpoints { + let listener = listen(addr).map_err(|source| ServerError::Bind { addr, source })?; let bound_addr = listener.local_addr()?; info!(%bound_addr, "Listening for BGP connections"); listeners.push((bound_addr, listener)); } + // Routes from the configuration go in before any session exists; + // each session is sent the whole table when it comes up. + let local_rib = LocalRib::spawn(); + for route in &config.originate { + local_rib.announce(route.clone()).await?; + } + let configured = config + .peers + .iter() + .map(|p| ConfiguredPeer { + name: p.name.clone(), + endpoint: p.endpoint, + remote_asn: p.remote_asn, + }) + .collect(); Ok(BgpServer { - config: self.config, listeners, - adj_rib_in: AdjRibIn::spawn(), + rib: Arc::new(RibTable::new(table_shards())), + local_rib, + registry: SessionRegistry::with_configured(configured), + config, }) } - /// Convenience method: bind then run immediately. - pub async fn run(self) -> Result<()> { - self.bind().await?.run().await + pub fn config(&self) -> &Config { + &self.config } -} -impl BgpServer { /// 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<()> { + /// Runs the server. Returns only if something it cannot do without has + /// failed, after closing its sessions. + pub async fn run(self) -> Result<(), ServerError> { + crate::metrics::init(); info!( router_id = %self.config.router_id, local_asn = self.config.local_asn, @@ -72,36 +125,268 @@ impl BgpServer { "BGP server started" ); - let session_config = Arc::new(SessionConfig { + let context = Arc::new(ServerContext { router_id: self.config.router_id, local_asn: self.config.local_asn, - hold_time_secs: self.config.hold_time_secs, - adj_rib_in: self.adj_rib_in.clone(), + rib: Arc::clone(&self.rib), + registry: self.registry.clone(), + collisions: CollisionTable::new(self.config.collision_detect_established_state), }); + // The tasks the server cannot do without. None of them ends while + // the server runs. The advertiser comes first: it has to be + // listening before a dialer brings up the first session. + let mut tasks: FuturesUnordered = FuturesUnordered::new(); + tasks.push(named( + "advertiser", + spawn_advertiser( + self.local_rib.clone(), + self.registry.clone(), + self.config.local_asn, + ), + )); + tasks.push(named("sweeper", spawn_sweeper(Arc::clone(&self.rib)))); + for dialer in spawn_dialers(&self.config.peers, &context, self.config.connect_retry) { + tasks.push(named("dialer", dialer)); + } + + let connections = ConnectionCounts::default(); + let mut refusals = Refusals::default(); let mut accepting: FuturesUnordered<_> = self .listeners .into_iter() .map(|(addr, listener)| accept_one(addr, listener)) .collect(); - while let Some((listen_addr, result, listener)) = accepting.next().await { + loop { + let (listen_addr, result, listener) = tokio::select! { + Some(accepted) = accepting.next() => accepted, + failure = failed(&self.local_rib, &mut tasks) => { + error!(err = %failure, "Shutting down: the server cannot continue"); + close_sessions(&self.registry).await; + return Err(failure); + } + }; match result { Ok((stream, peer_addr)) => { - tokio::spawn(handle_connection( - stream, - peer_addr, - Arc::clone(&session_config), - )); + match admit(&self.config, peer_addr.ip(), &connections) { + Ok((peer, slot)) => { + let context = Arc::clone(&context); + tokio::spawn(serve(stream, peer_addr, context, peer, slot)); + } + // Closed by being dropped, with nothing read and + // nothing said: a host we do not know learns no + // more than that the port is open. + Err(refusal) => refusals.note(peer_addr, refusal), + } } Err(e) => { error!(%listen_addr, err = %e, "Accept error"); + tokio::time::sleep(ACCEPT_RETRY).await; } } accepting.push(accept_one(listen_addr, listener)); } + } +} - Ok(()) +/// Closes every session politely and waits a moment for them to go: each +/// peer is told we are going (Cease / Administrative Shutdown), so that it +/// stops using our routes now and not when its hold timer expires. +pub async fn close_sessions(registry: &SessionRegistry) { + let left = registry.shutdown(SHUTDOWN_GRACE).await; + if left > 0 { + warn!(sessions = left, "Some sessions did not close in time"); + } +} + +/// Opens a listening socket on `addr`. +fn listen(addr: SocketAddr) -> io::Result { + let socket = match addr { + SocketAddr::V4(_) => TcpSocket::new_v4()?, + SocketAddr::V6(_) => { + let socket = TcpSocket::new_v6()?; + // IPv6 only. Where a system's default is to take IPv4 + // connections on such a socket too, listening on both 0.0.0.0 + // and [::] would fail on the second, and IPv4 peers would come + // in under IPv4-mapped addresses that match no configured peer. + socket2::SockRef::from(&socket).set_only_v6(true)?; + socket + } + }; + socket.set_reuseaddr(true)?; + socket.bind(addr)?; + socket.listen(LISTEN_BACKLOG) +} + +/// Decides whether to take a connection from `address`, and if so with what +/// settings. +fn admit( + config: &Config, + address: IpAddr, + connections: &ConnectionCounts, +) -> Result<(PeerSettings, ConnectionSlot), Refusal> { + // A connection from a configured peer is run with what is configured + // for that peer. + let peer = match config.peer_at(address) { + Some(peer) => PeerSettings::from(peer), + None if config.accept_unconfigured_peers => { + PeerSettings::unconfigured(config.hold_time_secs) + } + None => return Err(Refusal::NotAPeer), + }; + let slot = connections.take(address).ok_or(Refusal::TooMany)?; + Ok((peer, slot)) +} + +/// Runs a session on a connection a peer opened, and logs how it ended. +/// `_slot` is the connection's place among those its address may have. +async fn serve( + stream: TcpStream, + peer_addr: SocketAddr, + context: Arc, + peer: PeerSettings, + _slot: ConnectionSlot, +) { + let end = run_session(stream, peer_addr, Direction::Inbound, context, peer).await; + if let Err(e) = end.result { + warn!(%peer_addr, err = %e, "BGP session terminated with error"); + } +} + +/// Why an incoming connection was closed without a session. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Refusal { + /// It is not from the address of a configured peer. + NotAPeer, + /// Its address has as many connections open as one may. + TooMany, +} + +impl Refusal { + fn as_str(self) -> &'static str { + match self { + Self::NotAPeer => "not a configured peer", + Self::TooMany => "too many connections from the address", + } + } +} + +/// Counts refused connections, and says so in the log now and then. +#[derive(Default)] +struct Refusals { + logged: Option, + /// How many were refused since the log last said so. + unlogged: u64, +} + +impl Refusals { + fn note(&mut self, peer_addr: SocketAddr, refusal: Refusal) { + CONNECTIONS_REFUSED + .with_label_values(&[refusal.as_str()]) + .inc(); + if self + .logged + .is_some_and(|logged| logged.elapsed() < REFUSAL_LOG_INTERVAL) + { + self.unlogged += 1; + return; + } + warn!( + %peer_addr, + reason = refusal.as_str(), + others_since_last_report = self.unlogged, + "Refused a connection" + ); + self.logged = Some(Instant::now()); + self.unlogged = 0; + } +} + +/// How many connections each address has open to us. +#[derive(Clone, Default)] +struct ConnectionCounts(Arc>>); + +impl ConnectionCounts { + /// A place for one more connection from `address`, unless it has as + /// many as it may. The place is given back when the slot is dropped. + fn take(&self, address: IpAddr) -> Option { + let mut counts = self.0.locked(); + let count = counts.entry(address).or_insert(0); + if *count >= MAX_CONNECTIONS_PER_ADDRESS { + return None; + } + *count += 1; + Some(ConnectionSlot { + counts: self.clone(), + address, + }) + } +} + +struct ConnectionSlot { + counts: ConnectionCounts, + address: IpAddr, +} + +impl Drop for ConnectionSlot { + fn drop(&mut self) { + let mut counts = self.counts.0.locked(); + if let Some(count) = counts.get_mut(&self.address) { + *count -= 1; + if *count == 0 { + counts.remove(&self.address); + } + } + } +} + +/// Drops, every so often, the attribute sets no route uses any more. +fn spawn_sweeper(rib: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let mut tick = tokio::time::interval(SWEEP_INTERVAL); + loop { + tick.tick().await; + let rib = Arc::clone(&rib); + // Takes each of the store's locks in turn and looks at every + // set: not for a thread that sessions run on. + let _ = tokio::task::spawn_blocking(move || rib.attrs().sweep()).await; + } + }) +} + +/// How many shards the routing table has. Sessions write to it from every +/// core at once; a good many more shards than cores keeps two of them from +/// wanting the same one. +fn table_shards() -> usize { + 16 * std::thread::available_parallelism().map_or(1, |cores| cores.get()) +} + +type NamedTask = Pin)> + Send>>; + +fn named(name: &'static str, task: JoinHandle<()>) -> NamedTask { + Box::pin(async move { (name, task.await) }) +} + +/// Resolves when something the server depends on has stopped: the local +/// RIB's actor or one of `tasks`. In a healthy server it never resolves. +/// +/// Nothing else would notice. A stopped RIB is only found out by whoever +/// next uses it, and a stopped task by nobody at all: the server would go +/// on accepting sessions while storing or announcing nothing. +async fn failed(local_rib: &LocalRib, tasks: &mut FuturesUnordered) -> ServerError { + tokio::select! { + () = local_rib.stopped() => RibStopped { rib: "local RIB" }.into(), + Some((task, result)) = tasks.next() => ServerError::TaskStopped { + task, + reason: match result { + Ok(()) => "it returned".into(), + Err(e) if e.is_panic() => { + format!("it panicked: {}", panic::message(e.into_panic().as_ref())) + } + Err(e) => e.to_string(), + }, + }, } } @@ -112,3 +397,60 @@ async fn accept_one( let result = listener.accept().await; (addr, result, listener) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The advertiser and the dialers run for as long as the server does. + /// If one of them panics, nothing else would ever find out. + #[tokio::test] + async fn a_task_that_panics_is_a_failure_of_the_server() { + let local_rib = LocalRib::spawn(); + let mut tasks: FuturesUnordered = FuturesUnordered::new(); + tasks.push(named("dialer", tokio::spawn(std::future::pending()))); + tasks.push(named( + "advertiser", + tokio::spawn(async { panic!("no route to announce") }), + )); + + let failure = failed(&local_rib, &mut tasks).await; + assert_eq!( + failure.to_string(), + "the advertiser task stopped unexpectedly: it panicked: no route to announce" + ); + } + + #[tokio::test] + async fn a_stopped_rib_is_a_failure_of_the_server() { + let local_rib = LocalRib::spawn(); + let mut tasks: FuturesUnordered = FuturesUnordered::new(); + tasks.push(named("advertiser", tokio::spawn(std::future::pending()))); + + local_rib.stop_for_testing(); + let failure = failed(&local_rib, &mut tasks).await; + assert!( + matches!(failure, ServerError::RibStopped(RibStopped { rib }) if rib == "local RIB") + ); + } + + /// One address may hold only so many connections, and a connection + /// that ends makes room for another. + #[test] + fn an_address_may_have_only_so_many_connections() { + let counts = ConnectionCounts::default(); + let address: IpAddr = "192.0.2.1".parse().unwrap(); + let other: IpAddr = "192.0.2.2".parse().unwrap(); + + let mut slots: Vec<_> = (0..MAX_CONNECTIONS_PER_ADDRESS) + .map(|_| counts.take(address).expect("room")) + .collect(); + assert!(counts.take(address).is_none()); + assert!(counts.take(other).is_some(), "each address has its own"); + + slots.pop(); + assert!(counts.take(address).is_some()); + drop(slots); + assert!(counts.0.locked().is_empty(), "nothing is kept for nobody"); + } +} diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs new file mode 100644 index 0000000..61ef7b5 --- /dev/null +++ b/crates/server/src/session.rs @@ -0,0 +1,294 @@ +//! Registry of established BGP sessions. +//! +//! A session registers itself when it reaches Established and is removed when +//! its [`SessionGuard`] drops, so a session that errors out is always +//! deregistered. Other parts of the server (the advertiser, the HTTP API) use +//! the registry to enumerate live sessions and queue outbound messages. + +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime}; + +use bgp_packet::errors::{BgpError, CeaseSubcode}; +use bgp_packet::message::NotificationMessage; + +use bgp_packet::message::Message; +use bgp_rib::table::PeerIndex; +use tokio::sync::{broadcast, mpsc}; + +use crate::collision::Direction; +use crate::family::FamilySet; +use crate::fsm::Negotiated; +use crate::metrics::SESSIONS_ESTABLISHED; +use crate::sync::IgnorePoisonRw; + +const EVENT_CHANNEL_CAPACITY: usize = 256; + +/// Identifies a session by the peer's TCP address and its AS number. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct PeerId { + pub addr: SocketAddr, + pub asn: u32, +} + +impl std::fmt::Display for PeerId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}(AS{})", self.addr, self.asn) + } +} + +/// Where the dialer is in bringing up a configured peer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DialState { + /// Not dialling: the peer connected to us, or the dialer has not started. + Idle, + Connecting, + /// The last attempt failed or the session ended; waiting to retry. + Backoff { + until: SystemTime, + /// How the last attempt or session ended. + last_error: String, + }, +} + +/// A peer from the configuration file, whether or not it is currently up. +#[derive(Debug, Clone)] +pub struct ConfiguredPeer { + pub name: String, + pub endpoint: SocketAddr, + pub remote_asn: u32, +} + +/// Handle to one established session. Cloning is cheap. +#[derive(Debug, Clone)] +pub struct SessionHandle { + pub peer: PeerId, + /// Name from the configuration, if this peer is configured. + pub name: Option, + /// Which side opened the TCP connection. + pub direction: Direction, + /// Our side of the TCP connection; used as the next hop for routes we + /// originate to this peer. + pub local_addr: SocketAddr, + pub established_at: SystemTime, + /// Whether the peer is in our own AS. + pub ibgp: bool, + /// Whether the peer negotiated RFC 6793 four-octet ASNs. Determines how + /// AS_PATH is encoded on the wire for this session. + pub four_octet_asn: bool, + /// The address families negotiated with the peer. Routes of any other + /// family are neither sent to it nor accepted from it. + pub families: FamilySet, + /// The families for which the peer sends us several routes for a + /// prefix (ADD-PATH, RFC 7911), and those for which we send it path + /// identifiers. + pub add_path_receive: FamilySet, + pub add_path_send: FamilySet, + /// The peer's number in the routing table: what its routes there are + /// marked with. + pub rib_peer: PeerIndex, + outbound: mpsc::Sender, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("session with {0} has closed")] +pub struct SessionClosed(pub PeerId); + +impl SessionHandle { + pub(crate) fn new( + peer: PeerId, + name: Option, + direction: Direction, + local_addr: SocketAddr, + negotiated: &Negotiated, + rib_peer: PeerIndex, + outbound: mpsc::Sender, + ) -> Self { + Self { + peer, + name, + direction, + local_addr, + established_at: SystemTime::now(), + ibgp: negotiated.ibgp, + four_octet_asn: negotiated.four_octet_asn, + families: negotiated.families.clone(), + add_path_receive: negotiated.add_path_receive.clone(), + add_path_send: negotiated.add_path_send.clone(), + rib_peer, + outbound, + } + } + + /// Whether `other` is a handle to this same session, and not to another + /// session with the same peer. + pub fn is_same_session(&self, other: &SessionHandle) -> bool { + self.outbound.same_channel(&other.outbound) + } + + /// Queues a message for transmission on this session. Waits for queue + /// space if the session's writer is behind. + pub async fn send(&self, msg: Message) -> Result<(), SessionClosed> { + self.outbound + .send(msg) + .await + .map_err(|_| SessionClosed(self.peer)) + } +} + +#[derive(Debug, Clone)] +pub enum SessionEvent { + Up(SessionHandle), + Down(PeerId), + /// The peer sent ROUTE-REFRESH (RFC 2918): it wants everything we + /// advertise to it sent again, typically because its import policy + /// changed. + RefreshRequested(SessionHandle), +} + +#[derive(Clone)] +pub struct SessionRegistry { + inner: Arc>>, + configured: Arc>, + dial_states: Arc>>, + shutting_down: Arc, + events: broadcast::Sender, +} + +impl Default for SessionRegistry { + fn default() -> Self { + Self::new() + } +} + +impl SessionRegistry { + pub fn new() -> Self { + Self::with_configured(vec![]) + } + + pub fn with_configured(configured: Vec) -> Self { + let (events, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); + let dial_states = configured + .iter() + .map(|p| (p.name.clone(), DialState::Idle)) + .collect(); + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + configured: Arc::new(configured), + dial_states: Arc::new(RwLock::new(dial_states)), + shutting_down: Arc::new(AtomicBool::new(false)), + events, + } + } + + /// Peers from the configuration file, each with the dialer's current + /// state for it. + pub fn configured_peers(&self) -> Vec<(ConfiguredPeer, DialState)> { + let states = self.dial_states.reading(); + self.configured + .iter() + .map(|p| { + ( + p.clone(), + states.get(&p.name).cloned().unwrap_or(DialState::Idle), + ) + }) + .collect() + } + + pub(crate) fn set_dial_state(&self, name: &str, state: DialState) { + self.dial_states.writing().insert(name.to_string(), state); + } + + /// An established session with this remote IP and ASN, regardless of + /// which side opened the TCP connection. + pub fn find(&self, ip: IpAddr, asn: u32) -> Option { + self.inner + .reading() + .values() + .find(|h| h.peer.addr.ip() == ip && h.peer.asn == asn) + .cloned() + } + + /// Adds a session and emits [`SessionEvent::Up`]. The returned guard + /// removes the session again when dropped. + pub(crate) fn register(&self, handle: SessionHandle) -> SessionGuard { + let peer = handle.peer; + self.inner.writing().insert(peer, handle.clone()); + SESSIONS_ESTABLISHED.inc(); + let _ = self.events.send(SessionEvent::Up(handle)); + SessionGuard { + registry: self.clone(), + peer, + } + } + + /// All currently established sessions, in no particular order. + pub fn list(&self) -> Vec { + self.inner.reading().values().cloned().collect() + } + + /// Called by a session when its peer asks for a route refresh. + pub(crate) fn request_refresh(&self, peer: &PeerId) { + let handle = self.inner.reading().get(peer).cloned(); + if let Some(handle) = handle { + let _ = self.events.send(SessionEvent::RefreshRequested(handle)); + } + } + + /// True once [`shutdown`](Self::shutdown) has begun; dialers stop + /// reconnecting. + pub fn is_shutting_down(&self) -> bool { + self.shutting_down.load(Ordering::Relaxed) + } + + /// Closes every session politely: each peer is sent Cease / + /// Administrative Shutdown (RFC 4486) so it can drop the session at once + /// instead of discovering a dead TCP connection. Waits up to `grace` for + /// the sessions to finish; returns how many were still open. + pub async fn shutdown(&self, grace: Duration) -> usize { + self.shutting_down.store(true, Ordering::Relaxed); + for session in self.list() { + // Not waited for: a session whose queue is full is one whose + // peer is not reading, and it would hold up the goodbye to all + // the others. It ends when the process does. + let _ = session + .outbound + .try_send(Message::Notification(NotificationMessage { + error: BgpError::Cease(CeaseSubcode::AdminShutdown), + data: vec![], + })); + } + let deadline = tokio::time::Instant::now() + grace; + while !self.list().is_empty() && tokio::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(20)).await; + } + self.list().len() + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + fn remove(&self, peer: &PeerId) { + let removed = self.inner.writing().remove(peer); + if removed.is_some() { + SESSIONS_ESTABLISHED.dec(); + let _ = self.events.send(SessionEvent::Down(*peer)); + } + } +} + +/// Removes the session from its registry on drop. +pub struct SessionGuard { + registry: SessionRegistry, + peer: PeerId, +} + +impl Drop for SessionGuard { + fn drop(&mut self) { + self.registry.remove(&self.peer); + } +} diff --git a/crates/server/src/sync.rs b/crates/server/src/sync.rs new file mode 100644 index 0000000..fa55249 --- /dev/null +++ b/crates/server/src/sync.rs @@ -0,0 +1,66 @@ +//! Locks that survive a panic. +//! +//! A standard lock is poisoned when a thread panics while holding it, and +//! every later `lock().unwrap()` then panics too. For the tables shared by +//! all sessions that would turn a bug in one session into the end of every +//! session, one panic at a time. +//! +//! The tables here are maps that are changed by single inserts and removes, +//! so a panic part-way through an update cannot leave one in a state that +//! is unsafe to read. The poison is therefore ignored: a session that +//! panics is cleaned up by its guards like one that ended any other way. + +use std::sync::{Mutex, MutexGuard, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +pub(crate) trait IgnorePoison { + /// Locks, whether or not an earlier holder panicked. + fn locked(&self) -> MutexGuard<'_, T>; +} + +impl IgnorePoison for Mutex { + fn locked(&self) -> MutexGuard<'_, T> { + self.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +pub(crate) trait IgnorePoisonRw { + /// Locks for reading, whether or not an earlier holder panicked. + fn reading(&self) -> RwLockReadGuard<'_, T>; + /// Locks for writing, whether or not an earlier holder panicked. + fn writing(&self) -> RwLockWriteGuard<'_, T>; +} + +impl IgnorePoisonRw for RwLock { + fn reading(&self) -> RwLockReadGuard<'_, T> { + self.read().unwrap_or_else(PoisonError::into_inner) + } + fn writing(&self) -> RwLockWriteGuard<'_, T> { + self.write().unwrap_or_else(PoisonError::into_inner) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn a_lock_whose_holder_panicked_can_still_be_taken() { + let mutex = Arc::new(Mutex::new(1)); + let rwlock = Arc::new(RwLock::new(1)); + let (m, r) = (mutex.clone(), rwlock.clone()); + let panicked = std::thread::spawn(move || { + let _m = m.lock().unwrap(); + let _r = r.write().unwrap(); + panic!("while holding both"); + }) + .join(); + assert!(panicked.is_err()); + assert!(mutex.is_poisoned() && rwlock.is_poisoned()); + + *mutex.locked() += 1; + *rwlock.writing() += 1; + assert_eq!((*mutex.locked(), *rwlock.reading()), (2, 2)); + } +} diff --git a/crates/server/src/tracing.rs b/crates/server/src/tracing.rs index 2c22065..afaa7de 100644 --- a/crates/server/src/tracing.rs +++ b/crates/server/src/tracing.rs @@ -1,26 +1,24 @@ use clap::Args; -use eyre::Result; use tracing::Level; use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::ParseError; /// 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 { - /// Set the minimum log level. - #[arg(short, long, global = true, default_value_t = Level::TRACE, help_heading = "Tracing")] + /// Set the minimum log level. At `debug` and below there is a line for + /// every UPDATE and every route announced. + #[arg(short, long, global = true, default_value_t = Level::INFO, help_heading = "Tracing")] level: Level, - /// Disable logging to stdout. - #[arg(long, global = true, default_value_t = false, help_heading = "Tracing")] - quiet: bool, } impl TracingArgs { - pub fn install(&self) -> Result<()> { + pub fn install(&self) -> Result<(), ParseError> { let filter = DEFAULT_TARGETS.into_iter().try_fold( EnvFilter::builder().from_env_lossy(), - |filter, target| -> Result<_> { + |filter, target| -> Result<_, ParseError> { Ok(filter.add_directive(format!("{}={}", target, self.level).parse()?)) }, )?; diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml new file mode 100644 index 0000000..051b34b --- /dev/null +++ b/crates/web/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "bgp-web" +description = "HTTP API, event stream and metrics for the BGP daemon" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +axum.workspace = true +bgp-packet.workspace = true +bgp-primitives.workspace = true +bgp-rib.workspace = true +bgp-server.workspace = true +futures.workspace = true +prometheus.workspace = true +serde.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +tracing.workspace = true + +[lints] +workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs new file mode 100644 index 0000000..5ca02dc --- /dev/null +++ b/crates/web/src/api.rs @@ -0,0 +1,406 @@ +//! JSON handlers and their view types. Everything here is read-only: the +//! daemon is configured through its configuration file. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::Json; +use std::collections::HashMap; + +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use bgp_packet::community; +use bgp_packet::message::{NlriNextHop, OriginPathAttribute, PathAttribute}; +use bgp_rib::table::{PeerIndex, Route}; +use bgp_rib::{LocalRoute, RibStopped}; +use bgp_server::session::{ConfiguredPeer, DialState, PeerId, SessionHandle, SessionRegistry}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; + +// --------------------------------------------------------------------------- +// 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 hostname: String, + pub started_at_unix: u64, + pub uptime_secs: u64, + pub sessions: usize, + pub local_routes: usize, + /// Routes learned from peers, counting each peer's separately. + pub received_routes: usize, + /// Prefixes with at least one such route, and so with a best route. + pub received_prefixes: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PeerView { + /// Name from the configuration file; absent for unconfigured inbound peers. + pub name: Option, + pub peer_addr: String, + pub peer_asn: u32, + /// `Established`, `Connecting`, `Backoff` or `Idle`. + pub state: String, + /// `inbound` or `outbound`: which side opened the live TCP connection. + pub direction: Option, + pub local_addr: Option, + pub established_at_unix: Option, + pub uptime_secs: Option, + pub four_octet_asn: Option, + /// The address families negotiated with the peer, e.g. `IPv4 unicast`. + pub address_families: Option>, + /// The families for which the peer sends us several routes for a + /// prefix (ADD-PATH, RFC 7911), and those for which we send it path + /// identifiers. + pub add_path_receive: Option>, + pub add_path_send: Option>, + /// Why the last connection attempt or session ended, while in Backoff. + pub last_error: Option, + pub retry_at_unix: Option, +} + +impl From<&SessionHandle> for PeerView { + fn from(s: &SessionHandle) -> Self { + Self { + name: s.name.clone(), + peer_addr: s.peer.addr.to_string(), + peer_asn: s.peer.asn, + state: "Established".into(), + direction: Some(s.direction.as_str().into()), + local_addr: Some(s.local_addr.to_string()), + established_at_unix: Some(unix_secs(s.established_at)), + uptime_secs: Some(s.established_at.elapsed().map(|d| d.as_secs()).unwrap_or(0)), + four_octet_asn: Some(s.four_octet_asn), + address_families: Some(s.families.iter().map(|f| f.to_string()).collect()), + add_path_receive: Some(s.add_path_receive.iter().map(|f| f.to_string()).collect()), + add_path_send: Some(s.add_path_send.iter().map(|f| f.to_string()).collect()), + last_error: None, + retry_at_unix: None, + } + } +} + +impl PeerView { + fn from_configured(peer: &ConfiguredPeer, state: &DialState) -> Self { + let (state_name, last_error, retry_at_unix) = match state { + DialState::Idle => ("Idle", None, None), + DialState::Connecting => ("Connecting", None, None), + DialState::Backoff { until, last_error } => { + ("Backoff", Some(last_error.clone()), Some(unix_secs(*until))) + } + }; + Self { + name: Some(peer.name.clone()), + peer_addr: peer.endpoint.to_string(), + peer_asn: peer.remote_asn, + state: state_name.into(), + direction: None, + local_addr: None, + established_at_unix: None, + uptime_secs: None, + four_octet_asn: None, + address_families: None, + add_path_receive: None, + add_path_send: None, + last_error, + retry_at_unix, + } + } +} + +/// Configured peers first (with their live session when established), then +/// any unconfigured inbound sessions. +pub fn peer_views(registry: &SessionRegistry) -> Vec { + let sessions = registry.list(); + let mut claimed = std::collections::HashSet::new(); + let mut views: Vec = registry + .configured_peers() + .iter() + .map(|(peer, state)| { + match sessions + .iter() + .find(|s| s.peer.addr.ip() == peer.endpoint.ip() && s.peer.asn == peer.remote_asn) + { + Some(session) => { + claimed.insert(session.peer); + PeerView { + name: Some(peer.name.clone()), + ..PeerView::from(session) + } + } + None => PeerView::from_configured(peer, state), + } + }) + .collect(); + let mut rest: Vec = sessions + .iter() + .filter(|s| !claimed.contains(&s.peer)) + .map(PeerView::from) + .collect(); + rest.sort_by(|a, b| a.peer_addr.cmp(&b.peer_addr)); + views.extend(rest); + views +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalRouteView { + pub prefix: 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(), + 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(attributes: &[PathAttribute], next_hop: Option<&NlriNextHop>) -> AttrSummary { + let mut s = AttrSummary { + // A multiprotocol route has its next hop here; an IPv4 route has it + // in the NEXT_HOP attribute below. + next_hop: next_hop.map(ToString::to_string), + ..Default::default() + }; + for attr in attributes { + 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::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, + /// The peer's identifier for the route: zero unless the peer sends + /// several routes for a prefix (ADD-PATH, RFC 7911). + pub path_id: u32, + /// Whether this is the route chosen for its prefix. + pub best: bool, + pub summary: AttrSummary, + /// The decoded attribute list. MP_REACH_NLRI is not in it: its next hop + /// is in the summary and its prefixes are routes of their own. + pub attributes: Vec, +} + +/// Who the routes in the table are from: the table knows peers by number, +/// the sessions know which number is whose. +pub(crate) struct PeerNames(HashMap); + +impl PeerNames { + pub(crate) fn of(registry: &SessionRegistry) -> Self { + Self( + registry + .list() + .into_iter() + .map(|session| (session.rib_peer, session.peer)) + .collect(), + ) + } + + /// A view of one route. `None` if the route's session has just ended: + /// the route is on its way out of the table. + pub(crate) fn view(&self, route: &Route) -> Option { + let peer = self.0.get(&route.peer)?; + // What a session stored was accepted by the decoder, and parses. + let (attributes, next_hop) = route.attrs.attributes().unwrap_or_default(); + Some(ReceivedRouteView { + peer_addr: peer.addr.to_string(), + peer_asn: peer.asn, + prefix: route.prefix.to_string(), + path_id: route.path_id, + best: route.best, + summary: summarize(&attributes, next_hop.as_ref()), + attributes, + }) + } +} + +fn origin_name(o: OriginPathAttribute) -> &'static str { + match o { + OriginPathAttribute::IGP => "IGP", + OriginPathAttribute::EGP => "EGP", + OriginPathAttribute::INCOMPLETE => "INCOMPLETE", + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// A table could not be read because its actor has stopped. The daemon is +/// on its way down; saying so beats answering with an empty table. +pub struct Unavailable(RibStopped); + +impl From for Unavailable { + fn from(e: RibStopped) -> Self { + Self(e) + } +} + +impl IntoResponse for Unavailable { + fn into_response(self) -> Response { + (StatusCode::SERVICE_UNAVAILABLE, self.0.to_string()).into_response() + } +} + +pub async fn status(State(state): State) -> Result, Unavailable> { + Ok(Json(StatusView { + router_id: state.info.router_id.to_string(), + local_asn: state.info.local_asn, + version: state.info.version.clone(), + hostname: state.info.hostname.clone(), + started_at_unix: unix_secs(state.info.started_at), + uptime_secs: state.info.started.elapsed().as_secs(), + sessions: state.registry.list().len(), + local_routes: state.local_rib.len().await?, + received_routes: state.rib.len(), + received_prefixes: state.rib.prefix_count(), + })) +} + +/// The configuration file the daemon was started with. +#[derive(Debug, Clone, Serialize)] +pub struct ConfigView { + pub path: Option, + /// Raw file contents as read at startup; later edits to the file are not + /// reflected until the daemon restarts. + pub text: Option, +} + +pub async fn config(State(state): State) -> Json { + let (path, text) = state.info.config.clone().unzip(); + Json(ConfigView { path, text }) +} + +pub async fn peers(State(state): State) -> Json> { + Json(peer_views(&state.registry)) +} + +pub async fn local_routes( + State(state): State, +) -> Result>, Unavailable> { + let mut routes: Vec = state + .local_rib + .snapshot() + .await? + .iter() + .map(LocalRouteView::from) + .collect(); + routes.sort_by(|a, b| a.prefix.cmp(&b.prefix)); + Ok(Json(routes)) +} + +/// How many routes [`received_routes`] returns unless asked for another +/// number. The table can hold hundreds of millions. +const DEFAULT_ROUTE_LIMIT: usize = 1000; + +/// The most routes [`received_routes`] returns, whatever is asked for: +/// each is copied out of the table and written as JSON. +const MAX_ROUTE_LIMIT: usize = 100_000; + +#[derive(Debug, Deserialize)] +pub struct RouteQuery { + /// At most this many routes. + limit: Option, +} + +/// Some of the routes learned from peers: all the routes of as many +/// prefixes as fit in the limit. `/api/status` has the totals. +pub async fn received_routes( + State(state): State, + Query(query): Query, +) -> Result>, StatusCode> { + let limit = query + .limit + .unwrap_or(DEFAULT_ROUTE_LIMIT) + .min(MAX_ROUTE_LIMIT); + let names = PeerNames::of(&state.registry); + // Copying, parsing and sorting many routes takes long enough to keep + // off the threads that sessions run on. + let routes = tokio::task::spawn_blocking(move || { + let mut routes: Vec = state + .rib + .some_routes(limit) + .iter() + .filter_map(|route| names.view(route)) + .collect(); + routes.sort_by(|a, b| (&a.prefix, &a.peer_addr).cmp(&(&b.prefix, &b.peer_addr))); + routes + }) + .await + // Only if making the views panicked. + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(routes)) +} diff --git a/crates/web/src/events.rs b/crates/web/src/events.rs new file mode 100644 index 0000000..c1ffe3c --- /dev/null +++ b/crates/web/src/events.rs @@ -0,0 +1,198 @@ +//! Server-sent event stream merging session, local-route and best-route +//! changes into one feed the UI can follow. + +use std::convert::Infallible; +use std::sync::Arc; + +use axum::extract::State; +use axum::response::sse::{Event, KeepAlive, Sse}; +use bgp_primitives::ip_prefix::IpPrefix; +use bgp_rib::LocalRibEvent; +use bgp_rib::table::{self, Route}; +use bgp_server::session::SessionEvent; +use futures::stream::{Stream, StreamExt}; +use serde::Serialize; +use tokio::sync::mpsc; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tokio_stream::wrappers::{BroadcastStream, ReceiverStream}; + +use crate::AppState; +use crate::api::{LocalRouteView, PeerNames, PeerView, ReceivedRouteView}; + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WireEvent { + SessionUp { + peer: PeerView, + }, + SessionDown { + peer_addr: String, + peer_asn: u32, + }, + /// The peer asked us to send all our routes again (ROUTE-REFRESH). + RouteRefreshRequested { + peer: PeerView, + }, + LocalAnnounced { + route: LocalRouteView, + }, + LocalWithdrawn { + prefix: String, + }, + /// The best route for a prefix is now this one. + BestChanged { + route: ReceivedRouteView, + }, + /// A prefix has no route any more. + BestWithdrawn { + prefix: String, + }, + /// The client fell behind and missed events on `stream` (`missed` of + /// them, where that is known); 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, + }, + Ok(SessionEvent::RefreshRequested(h)) => Self::RouteRefreshRequested { + peer: PeerView::from(&h), + }, + 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, + }, + } + } +} + +/// The table's changes of best route as events, from when the client +/// connects. Each client follows the table's journal with a reader of its +/// own, so one that reads slowly holds nobody else up. +fn best_routes(state: &AppState) -> impl Stream + use<> { + let (events, stream) = mpsc::channel(BEST_ROUTE_BUFFER); + let registry = state.registry.clone(); + // Taken before this returns, so that nothing that happens after the + // client is answered is missed. + let mut reader = state.rib.reader_from_now(); + tokio::spawn(async move { + // If the reader falls too far behind it is given the whole table + // again. That is not news: the client is told it missed something + // and gets its table from the API. + let mut syncing = false; + loop { + tokio::select! { + () = reader.changed() => {} + () = events.closed() => return, + } + loop { + // Only what is needed is taken while the table is held; + // the views are made afterwards. + let mut changes = Vec::new(); + let progress = reader.poll(BEST_ROUTE_BUFFER, |event| match event { + table::Event::Reset => { + syncing = true; + changes.push(Change::Lagged); + } + table::Event::Synced => syncing = false, + table::Event::Best { + prefix, + peer, + path_id, + attrs, + } if !syncing => { + changes.push(Change::Best(Route { + prefix, + peer, + path_id, + attrs: Arc::clone(attrs), + best: true, + })); + } + table::Event::Withdrawn { prefix } if !syncing => { + changes.push(Change::Withdrawn(prefix)); + } + _ => {} + }); + let names = PeerNames::of(®istry); + for change in changes { + let event = match change { + Change::Best(route) => match names.view(&route) { + Some(route) => WireEvent::BestChanged { route }, + None => continue, + }, + Change::Withdrawn(prefix) => WireEvent::BestWithdrawn { + prefix: prefix.to_string(), + }, + Change::Lagged => WireEvent::Lagged { + stream: "best_routes", + missed: 0, + }, + }; + if events.send(event).await.is_err() { + return; + } + } + if progress.caught_up { + break; + } + } + } + }); + ReceiverStream::new(stream) +} + +/// How many best-route events are read from the journal, and queued for a +/// client, at a time. +const BEST_ROUTE_BUFFER: usize = 256; + +enum Change { + Best(Route), + Withdrawn(IpPrefix), + /// The client's reader fell too far behind the table. + Lagged, +} + +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 = best_routes(&state); + + 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..5e65f24 --- /dev/null +++ b/crates/web/src/lib.rs @@ -0,0 +1,127 @@ +//! HTTP surface of the daemon: a read-only JSON API for inspecting state, a +//! status page, a server-sent event stream, and Prometheus metrics. +//! +//! Everything here is a thin layer over handles owned by the BGP server +//! ([`SessionRegistry`], [`LocalRib`], [`RibTable`]); no routing state lives +//! in this crate. + +mod api; +mod events; +mod metrics; + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::time::{Instant, SystemTime}; + +use axum::Router; +use axum::http::header; +use axum::response::{Html, IntoResponse, Redirect}; +use axum::routing::get; +use bgp_rib::LocalRib; +use bgp_rib::table::RibTable; +use bgp_server::session::SessionRegistry; +use tokio::net::TcpListener; +use tracing::info; + +pub use api::{AttrSummary, ConfigView, LocalRouteView, PeerView, ReceivedRouteView, StatusView}; + +/// 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, + /// Wall-clock start time, for display. + pub started_at: SystemTime, + /// Name of the machine we run on, for display. + pub hostname: String, + /// The configuration file as read at startup (path and raw text), shown + /// on the status page. `None` when the server was configured in code. + pub config: Option<(String, String)>, +} + +impl RouterInfo { + pub fn new(router_id: Ipv4Addr, local_asn: u32, version: impl Into) -> Self { + Self { + router_id, + local_asn, + version: version.into(), + started: Instant::now(), + started_at: SystemTime::now(), + hostname: hostname(), + config: None, + } + } + + /// Records the configuration file so the status page can show it. + pub fn with_config(mut self, path: impl Into, text: impl Into) -> Self { + self.config = Some((path.into(), text.into())); + self + } +} + +/// Best-effort host name without pulling in a dependency: ask the `hostname` +/// utility, which exists on every platform we run on. +fn hostname() -> String { + std::process::Command::new("hostname") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown host".into()) +} + +#[derive(Clone)] +pub struct AppState { + pub info: Arc, + pub registry: SessionRegistry, + pub local_rib: LocalRib, + /// The routing table: routes learned from peers and the best of them. + pub rib: Arc, +} + +/// The status page, embedded so the binary is self-contained. +const STATUSZ_HTML: &str = include_str!("../static/statusz.html"); + +async fn statusz() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + Html(STATUSZ_HTML), + ) +} + +/// Builds the router. Exposed so tests can drive it without a socket. +pub fn router(state: AppState) -> Router { + Router::new() + .route("/", get(|| async { Redirect::temporary("/statusz") })) + .route("/statusz", get(statusz)) + .route("/api/status", get(api::status)) + .route("/api/config", get(api::config)) + .route("/api/peers", get(api::peers)) + .route("/api/routes/local", get(api::local_routes)) + .route("/api/routes/received", get(api::received_routes)) + .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) -> std::io::Result<()> { + let listener = TcpListener::bind(listen).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to bind HTTP listener on {listen}: {e}"), + ) + })?; + 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) -> std::io::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..ec9ae83 --- /dev/null +++ b/crates/web/src/metrics.rs @@ -0,0 +1,38 @@ +//! 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::{ + LOCAL_ROUTES, RIB_ATTRIBUTE_SETS, RIB_JOURNAL_ENTRIES, RIB_PREFIXES, RIB_ROUTES, +}; +use prometheus::{Encoder, TextEncoder}; + +use crate::AppState; + +pub async fn metricz(State(state): State) -> impl IntoResponse { + // A scrape that cannot read a table fails. Serving the last known + // size would hide that the daemon is broken from the one system whose + // job is to notice. + let gauge = |count: usize| i64::try_from(count).unwrap_or(i64::MAX); + match state.local_rib.len().await { + Ok(local) => LOCAL_ROUTES.set(gauge(local)), + Err(e) => return (StatusCode::SERVICE_UNAVAILABLE, e.to_string()).into_response(), + } + RIB_ROUTES.set(gauge(state.rib.len())); + RIB_PREFIXES.set(gauge(state.rib.prefix_count())); + RIB_ATTRIBUTE_SETS.set(gauge(state.rib.attrs().len())); + RIB_JOURNAL_ENTRIES.set(gauge(state.rib.journal_len())); + + 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/crates/web/static/statusz.html b/crates/web/static/statusz.html new file mode 100644 index 0000000..4d1f3f8 --- /dev/null +++ b/crates/web/static/statusz.html @@ -0,0 +1,260 @@ + + + + + +Status for ferrix_bgp + + + + + + +

Status for ferrix_bgp

+ +
+
+Started: ... (up ...)
+Version ...
+Router ID ..., local AS ...
+
+ +
+ + + +

Peers (...)

+ + + +
namepeerASstateopened bylocal address (next hop)establisheduptime4-byte ASaddress familiesdetail
+ +

Originated routes (...)

+ + + +
prefixoriginAS pathMEDcommunitieslarge communitiessince
+ +

Received routes (...)

+ + + +
prefixfrom peerAS pathnext hoporiginMEDlocal prefcommunitieslarge communitiesbest
+ +

Event log

+
+
+ +
ferrix_bgp · /statusz /metricz /api/events
+ + + + diff --git a/docs/RFCs.md b/docs/RFCs.md index b37f359..832c0e0 100644 --- a/docs/RFCs.md +++ b/docs/RFCs.md @@ -25,9 +25,17 @@ The base protocol specification. | §4.4 | KEEPALIVE message | ✅ | | §4.5 | NOTIFICATION message (all error codes and subcodes) | ✅ | | §8 | FSM — passive (inbound) side: OpenSent → OpenConfirm → Established | ✅ | -| §8 | FSM — active (outbound) side: Idle → Connect → Active | ❌ | +| §8 | FSM — active (outbound) side: dial configured peers, exponential retry | ✅ | +| §6.2 | Bad Peer AS check against configured ASN | ✅ | +| §6.7 / RFC 4486 | Cease / Administrative Shutdown sent to every peer on SIGINT or SIGTERM | ✅ | +| §6.8 | Connection collision detection (BGP Identifier comparison, Cease / Connection Collision Resolution) | ✅ | +| §8.1.1 | Optional `CollisionDetectEstablishedState` (`collision_detect_established_state`) | ✅ | +| §6.2 | Bad BGP Identifier check (peer using our identifier, or 0.0.0.0) | ✅ | | §9 | Decision process / best-path selection | ❌ | -| §9 | RIB (Adj-RIB-In, Loc-RIB, Adj-RIB-Out) | ❌ | +| §9 | Adj-RIB-In (per-peer table of received routes) | ✅ | +| §9 | Locally originated routes, advertised to all established peers | ✅ | +| §9 | Loc-RIB / Adj-RIB-Out for routes learned from peers (re-advertisement) | ❌ | +| §9.2 | Hold timer reset on any received message (not only KEEPALIVE) | ✅ | --- @@ -59,7 +67,7 @@ https://datatracker.ietf.org/doc/html/rfc2918 | `RouteRefresh` capability advertisement | ✅ | | `ROUTE-REFRESH` message type (5) framing | ✅ | | AFI/SAFI payload parsing | ❌ | -| Acting on received ROUTE-REFRESH (re-advertising routes) | ❌ | +| Acting on received ROUTE-REFRESH (re-advertising all local routes, regardless of the AFI/SAFI asked for) | ✅ | ### RFC 7313 — Enhanced Route Refresh Capability for BGP-4 https://datatracker.ietf.org/doc/html/rfc7313 @@ -100,7 +108,7 @@ https://datatracker.ietf.org/doc/html/rfc2858 | Feature | Status | |---------|--------| -| `MultiProtocol` capability advertisement (IPv4 unicast) | ✅ | +| `MultiProtocol` capability advertisement (IPv4 and IPv6 unicast) | ✅ | | `MP_REACH_NLRI` path attribute (parse, serialise) | ✅ | | `MP_UNREACH_NLRI` path attribute (parse, serialise) | ✅ | | IPv4 and IPv6 next-hop encoding | ✅ | @@ -122,6 +130,7 @@ https://datatracker.ietf.org/doc/html/rfc1997 | Feature | Status | |---------|--------| | `COMMUNITIES` path attribute (parse, serialise) | ✅ | +| Attach to locally originated routes | ✅ | ### RFC 4360 — BGP Extended Communities Attribute https://datatracker.ietf.org/doc/html/rfc4360 @@ -137,6 +146,7 @@ https://datatracker.ietf.org/doc/html/rfc8092 | Feature | Status | |---------|--------| | `LARGE_COMMUNITIES` path attribute (parse, serialise) | ✅ | +| Attach to locally originated routes | ✅ | --- diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..45eaf5a --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,6 @@ +# What a fuzzing run produces. Seeds worth keeping live in seeds/. +target/ +corpus/ +artifacts/ +coverage/ +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..498713c --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,54 @@ +# Coverage-guided fuzz targets, run with cargo-fuzz on a nightly toolchain. +# See README.md in this directory. +[package] +name = "bgp-fuzz" +version = "0.0.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[package.metadata] +cargo-fuzz = true + +# Not part of the main workspace: libfuzzer needs nightly, the workspace +# builds on stable. +[workspace] + +[dependencies] +bgp-packet = { path = "../crates/packet" } +bgp-primitives = { path = "../crates/primitives" } +bgp-server = { path = "../crates/server" } +bytes = "1.10.1" +libfuzzer-sys = "0.4" +protobuf = "3.7.2" + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "message" +path = "fuzz_targets/message.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "message_body" +path = "fuzz_targets/message_body.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "prefix_text" +path = "fuzz_targets/prefix_text.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "config" +path = "fuzz_targets/config.rs" +test = false +doc = false +bench = false diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..a62241b --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,47 @@ +# Fuzz targets + +Coverage-guided fuzzing of everything that reads input we do not control. +The property tests (`cargo test`, e.g. `crates/packet/tests/properties.rs`) +check the same things on generated input and run on every build; these +targets go further by letting libFuzzer follow code coverage for as long as +you leave them running. + +| Target | Input | +|----------------|------------------------------------------------------------------| +| `message` | Raw bytes as they come off a TCP connection | +| `message_body` | A message body behind a correct header (reaches the body parsers) | +| `prefix_text` | A prefix as text | +| `config` | The configuration file | + +For the two message targets a finding is either a panic, or a message the +decoder accepted that cannot be written and read back as itself. + +## Running + +libFuzzer needs a nightly toolchain and `cargo-fuzz`: + +```bash +rustup toolchain install nightly +cargo install cargo-fuzz +``` + +Then, from this directory: + +```bash +cargo +nightly fuzz run message_body corpus/message_body seeds/message_body -- -max_total_time=300 +``` + +The first directory is the working corpus, which the fuzzer adds to and +which is not checked in. `seeds/` holds a few valid inputs to start from. +Leave out `-max_total_time` to run until interrupted. + +## When it finds something + +The input is saved under `artifacts//`. Reproduce it with + +```bash +cargo +nightly fuzz run message_body artifacts/message_body/crash-... +``` + +Fix the bug, and add a unit test with the input next to the code it broke, +so that `cargo test` keeps it fixed without anyone having to run the fuzzer. diff --git a/fuzz/fuzz_targets/config.rs b/fuzz/fuzz_targets/config.rs new file mode 100644 index 0000000..adbc213 --- /dev/null +++ b/fuzz/fuzz_targets/config.rs @@ -0,0 +1,13 @@ +//! The configuration file. It is written by the operator and not by a peer, +//! but a typo in it should be an error message, never a panic at startup. +#![no_main] + +use bgp_server::config::Config; +use bgp_server::proto::config::BgpConfig; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|text: &str| { + if let Ok(proto) = protobuf::text_format::parse_from_str::(text) { + let _ = Config::from_proto(proto); + } +}); diff --git a/fuzz/fuzz_targets/message.rs b/fuzz/fuzz_targets/message.rs new file mode 100644 index 0000000..9bdef17 --- /dev/null +++ b/fuzz/fuzz_targets/message.rs @@ -0,0 +1,12 @@ +//! Raw bytes, as they come off a TCP connection. The first byte picks what the +//! session settled: see `bgp_fuzz::Session`. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + let Some((selector, wire)) = data.split_first() else { + return; + }; + bgp_fuzz::decode_and_check(bgp_fuzz::Session::from_bits(*selector), wire); +}); diff --git a/fuzz/fuzz_targets/message_body.rs b/fuzz/fuzz_targets/message_body.rs new file mode 100644 index 0000000..47dae99 --- /dev/null +++ b/fuzz/fuzz_targets/message_body.rs @@ -0,0 +1,25 @@ +//! A message body behind a correct header, so that the fuzzer spends its +//! time in the OPEN, UPDATE and NOTIFICATION parsers and not on guessing +//! the marker and length. The first byte picks the message type and what +//! the session settled: see `bgp_fuzz::Session`. +#![no_main] + +use libfuzzer_sys::fuzz_target; + +/// The longest body that fits a BGP message (4096 less the 19-byte header). +const MAX_BODY: usize = 4096 - 19; + +fuzz_target!(|data: &[u8]| { + let Some((selector, body)) = data.split_first() else { + return; + }; + let body = &body[..body.len().min(MAX_BODY)]; + // Bits 1 to 3; the others pick the session. + let msg_type = 1 + ((selector >> 1) & 0x07) % 5; + + let mut wire = vec![0xff; 16]; + wire.extend_from_slice(&((19 + body.len()) as u16).to_be_bytes()); + wire.push(msg_type); + wire.extend_from_slice(body); + bgp_fuzz::decode_and_check(bgp_fuzz::Session::from_bits(*selector), &wire); +}); diff --git a/fuzz/fuzz_targets/prefix_text.rs b/fuzz/fuzz_targets/prefix_text.rs new file mode 100644 index 0000000..e326aaa --- /dev/null +++ b/fuzz/fuzz_targets/prefix_text.rs @@ -0,0 +1,12 @@ +//! Prefixes as text, as they appear in the configuration file. +#![no_main] + +use bgp_primitives::ip_prefix::IpPrefix; +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|text: &str| { + if let Ok(prefix) = IpPrefix::try_from(text) { + let shown = prefix.to_string(); + assert_eq!(IpPrefix::try_from(shown.as_str()), Ok(prefix)); + } +}); diff --git a/fuzz/seeds/config/00 b/fuzz/seeds/config/00 new file mode 100644 index 0000000..63dfe35 --- /dev/null +++ b/fuzz/seeds/config/00 @@ -0,0 +1,38 @@ +# A worked example; the fields are documented in +# crates/server/proto/config.proto. +router_id: "10.0.0.1" +local_asn: 65000 + +# Where to accept BGP connections. Port 179 needs privileges; pick another +# port to run unprivileged against a peer that dials a custom port. +listen_endpoints { address: "127.0.0.1" port: 179 } +listen_endpoints { address: "::1" port: 179 } + +# A peer we connect to (and accept connections from). +peers { + name: "r1" + endpoint { address: "127.0.0.1" port: 1791 } + remote_asn: 65001 + hold_time_seconds: 30 + # Offer only these families to the peer. Left out, all are offered. + address_families: IPV4_UNICAST + address_families: IPV6_UNICAST +} +connect_retry_seconds: 5 + +# Let a restarted peer replace a stale Established session immediately +# (RFC 4271 CollisionDetectEstablishedState). Default false. +collision_detect_established_state: false + +# Routes this router originates, announced to every peer. +originate { prefix: "10.100.0.0/16" } +originate { + prefix: "2001:db8:100::/48" + med: 50 + communities: "65000:100" + large_communities: "65000:1:2" +} + +# Read-only status page, JSON API and metrics. It has no authentication: +# keep it on loopback or behind a reverse proxy. +http_endpoint { address: "127.0.0.1" port: 8080 } diff --git a/fuzz/seeds/message/00 b/fuzz/seeds/message/00 new file mode 100644 index 0000000..2daddbe Binary files /dev/null and b/fuzz/seeds/message/00 differ diff --git a/fuzz/seeds/message/01 b/fuzz/seeds/message/01 new file mode 100644 index 0000000..593e824 Binary files /dev/null and b/fuzz/seeds/message/01 differ diff --git a/fuzz/seeds/message/02 b/fuzz/seeds/message/02 new file mode 100644 index 0000000..0ff6ae1 Binary files /dev/null and b/fuzz/seeds/message/02 differ diff --git a/fuzz/seeds/message/03 b/fuzz/seeds/message/03 new file mode 100644 index 0000000..860d79b Binary files /dev/null and b/fuzz/seeds/message/03 differ diff --git a/fuzz/seeds/message/04 b/fuzz/seeds/message/04 new file mode 100644 index 0000000..1adf33d Binary files /dev/null and b/fuzz/seeds/message/04 differ diff --git a/fuzz/seeds/message/05 b/fuzz/seeds/message/05 new file mode 100644 index 0000000..009cdc3 Binary files /dev/null and b/fuzz/seeds/message/05 differ diff --git a/fuzz/seeds/message/06 b/fuzz/seeds/message/06 new file mode 100644 index 0000000..20b66d5 Binary files /dev/null and b/fuzz/seeds/message/06 differ diff --git a/fuzz/seeds/message/07 b/fuzz/seeds/message/07 new file mode 100644 index 0000000..04d5475 Binary files /dev/null and b/fuzz/seeds/message/07 differ diff --git a/fuzz/seeds/message/08 b/fuzz/seeds/message/08 new file mode 100644 index 0000000..2e13427 Binary files /dev/null and b/fuzz/seeds/message/08 differ diff --git a/fuzz/seeds/message/09 b/fuzz/seeds/message/09 new file mode 100644 index 0000000..bc1206e Binary files /dev/null and b/fuzz/seeds/message/09 differ diff --git a/fuzz/seeds/message/10 b/fuzz/seeds/message/10 new file mode 100644 index 0000000..c4bc75b Binary files /dev/null and b/fuzz/seeds/message/10 differ diff --git a/fuzz/seeds/message/11 b/fuzz/seeds/message/11 new file mode 100644 index 0000000..94db047 Binary files /dev/null and b/fuzz/seeds/message/11 differ diff --git a/fuzz/seeds/message/12 b/fuzz/seeds/message/12 new file mode 100644 index 0000000..be2f2cc Binary files /dev/null and b/fuzz/seeds/message/12 differ diff --git a/fuzz/seeds/message/13 b/fuzz/seeds/message/13 new file mode 100644 index 0000000..de9cf0d Binary files /dev/null and b/fuzz/seeds/message/13 differ diff --git a/fuzz/seeds/message/14 b/fuzz/seeds/message/14 new file mode 100644 index 0000000..3200a2f Binary files /dev/null and b/fuzz/seeds/message/14 differ diff --git a/fuzz/seeds/message/15 b/fuzz/seeds/message/15 new file mode 100644 index 0000000..0d1029f Binary files /dev/null and b/fuzz/seeds/message/15 differ diff --git a/fuzz/seeds/message_body/00 b/fuzz/seeds/message_body/00 new file mode 100644 index 0000000..23852c7 Binary files /dev/null and b/fuzz/seeds/message_body/00 differ diff --git a/fuzz/seeds/message_body/01 b/fuzz/seeds/message_body/01 new file mode 100644 index 0000000..989cfb9 Binary files /dev/null and b/fuzz/seeds/message_body/01 differ diff --git a/fuzz/seeds/message_body/02 b/fuzz/seeds/message_body/02 new file mode 100644 index 0000000..b7fcc69 Binary files /dev/null and b/fuzz/seeds/message_body/02 differ diff --git a/fuzz/seeds/message_body/03 b/fuzz/seeds/message_body/03 new file mode 100644 index 0000000..a68ff5b Binary files /dev/null and b/fuzz/seeds/message_body/03 differ diff --git a/fuzz/seeds/message_body/04 b/fuzz/seeds/message_body/04 new file mode 100644 index 0000000..b93d695 Binary files /dev/null and b/fuzz/seeds/message_body/04 differ diff --git a/fuzz/seeds/message_body/05 b/fuzz/seeds/message_body/05 new file mode 100644 index 0000000..d2f8b81 Binary files /dev/null and b/fuzz/seeds/message_body/05 differ diff --git a/fuzz/seeds/message_body/06 b/fuzz/seeds/message_body/06 new file mode 100644 index 0000000..42e18a6 Binary files /dev/null and b/fuzz/seeds/message_body/06 differ diff --git a/fuzz/seeds/message_body/07 b/fuzz/seeds/message_body/07 new file mode 100644 index 0000000..89ae252 Binary files /dev/null and b/fuzz/seeds/message_body/07 differ diff --git a/fuzz/seeds/message_body/08 b/fuzz/seeds/message_body/08 new file mode 100644 index 0000000..00315ca Binary files /dev/null and b/fuzz/seeds/message_body/08 differ diff --git a/fuzz/seeds/message_body/09 b/fuzz/seeds/message_body/09 new file mode 100644 index 0000000..515b6a5 Binary files /dev/null and b/fuzz/seeds/message_body/09 differ diff --git a/fuzz/seeds/message_body/10 b/fuzz/seeds/message_body/10 new file mode 100644 index 0000000..679ddc1 --- /dev/null +++ b/fuzz/seeds/message_body/10 @@ -0,0 +1 @@ +bye \ No newline at end of file diff --git a/fuzz/seeds/message_body/11 b/fuzz/seeds/message_body/11 new file mode 100644 index 0000000..a6ff320 --- /dev/null +++ b/fuzz/seeds/message_body/11 @@ -0,0 +1 @@ +bye \ No newline at end of file diff --git a/fuzz/seeds/message_body/12 b/fuzz/seeds/message_body/12 new file mode 100644 index 0000000..f8fa5a2 --- /dev/null +++ b/fuzz/seeds/message_body/12 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/seeds/message_body/13 b/fuzz/seeds/message_body/13 new file mode 100644 index 0000000..303e398 --- /dev/null +++ b/fuzz/seeds/message_body/13 @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/fuzz/seeds/message_body/14 b/fuzz/seeds/message_body/14 new file mode 100644 index 0000000..66fd013 Binary files /dev/null and b/fuzz/seeds/message_body/14 differ diff --git a/fuzz/seeds/message_body/15 b/fuzz/seeds/message_body/15 new file mode 100644 index 0000000..3adce1a Binary files /dev/null and b/fuzz/seeds/message_body/15 differ diff --git a/fuzz/seeds/prefix_text/00 b/fuzz/seeds/prefix_text/00 new file mode 100644 index 0000000..814f80c --- /dev/null +++ b/fuzz/seeds/prefix_text/00 @@ -0,0 +1 @@ +10.0.0.0/8 \ No newline at end of file diff --git a/fuzz/seeds/prefix_text/01 b/fuzz/seeds/prefix_text/01 new file mode 100644 index 0000000..9cbba47 --- /dev/null +++ b/fuzz/seeds/prefix_text/01 @@ -0,0 +1 @@ +2001:db8::/32 \ No newline at end of file diff --git a/fuzz/seeds/prefix_text/02 b/fuzz/seeds/prefix_text/02 new file mode 100644 index 0000000..3591f8e --- /dev/null +++ b/fuzz/seeds/prefix_text/02 @@ -0,0 +1 @@ +0.0.0.0/0 \ No newline at end of file diff --git a/fuzz/seeds/prefix_text/03 b/fuzz/seeds/prefix_text/03 new file mode 100644 index 0000000..c9c0053 --- /dev/null +++ b/fuzz/seeds/prefix_text/03 @@ -0,0 +1 @@ +::/0 \ No newline at end of file diff --git a/fuzz/seeds/prefix_text/04 b/fuzz/seeds/prefix_text/04 new file mode 100644 index 0000000..0fbe032 --- /dev/null +++ b/fuzz/seeds/prefix_text/04 @@ -0,0 +1 @@ +192.0.2.1/32 \ No newline at end of file diff --git a/fuzz/seeds/prefix_text/05 b/fuzz/seeds/prefix_text/05 new file mode 100644 index 0000000..8b3bc5b --- /dev/null +++ b/fuzz/seeds/prefix_text/05 @@ -0,0 +1 @@ +10.1.2.3/19 \ No newline at end of file diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 0000000..2f1825e --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,72 @@ +//! What the fuzz targets have in common. + +use bgp_packet::message::Message; +use bgp_primitives::constants::AfiSafi; +use bgp_primitives::parser::{EncodeError, ParserContext}; +use bytes::BytesMut; + +/// What a session settled that decoding depends on. +#[derive(Debug, Clone, Copy)] +pub struct Session { + pub four_octet_asn: bool, + pub ibgp: bool, + /// Whether prefixes carry path identifiers (RFC 7911), for IPv4 and for + /// IPv6 unicast. + pub add_path: [bool; 2], +} + +impl Session { + /// A session picked by bits of `selector`: bit 0 for the AS number + /// width, bits 5 and 6 for ADD-PATH, bit 7 for an internal peer. Bits 1 + /// to 4 are left for the target. + pub fn from_bits(selector: u8) -> Self { + Self { + four_octet_asn: selector & 0x01 != 0, + add_path: [selector & 0x20 != 0, selector & 0x40 != 0], + ibgp: selector & 0x80 != 0, + } + } + + fn context(self) -> ParserContext { + ParserContext { + four_octet_asn: self.four_octet_asn, + ibgp: self.ibgp, + add_path: [AfiSafi::IPV4_UNICAST, AfiSafi::IPV6_UNICAST] + .into_iter() + .zip(self.add_path) + .filter_map(|(family, in_use)| in_use.then_some(family)) + .collect(), + } + } +} + +/// Decodes `wire` as a peer's message on `session`. Whatever the bytes, this must return: a panic is a way for a peer +/// to take a session down. +/// +/// A message that is accepted must also be one we can show, write, and read +/// back as itself. Anything else means the decoder built a value the rest +/// of the program cannot rely on. +pub fn decode_and_check(session: Session, wire: &[u8]) { + let ctx = session.context(); + let Ok(decoded) = Message::decode_with_report(&ctx, wire) else { + return; + }; + let (msg, repairs) = (decoded.message, decoded.update_errors); + let _ = format!("{msg:?} {repairs:?}"); + + let mut rewritten = BytesMut::new(); + match msg.to_wire(&ctx, &mut rewritten) { + Ok(()) => {} + // Withdrawing what a full-size UPDATE announced can need more room + // than one message has. The result is acted on, never sent. + Err(EncodeError::TooLong { .. }) if !repairs.is_empty() => return, + Err(e) => panic!("an accepted message cannot be written: {e}"), + } + // An UPDATE the decoder had to repair (RFC 7606) is not what was on the + // wire, and need not read back as itself. + if repairs.is_empty() { + let again = Message::decode_with_report(&ctx, &rewritten).expect("and read back"); + assert!(again.update_errors.is_empty(), "{:?}", again.update_errors); + assert_eq!(again.message, msg); + } +} diff --git a/testdata/server.textpb b/testdata/server.textpb index 1985ed9..de239b0 100644 --- a/testdata/server.textpb +++ b/testdata/server.textpb @@ -1,10 +1,43 @@ +# A worked example; the fields are documented in +# crates/server/proto/config.proto. router_id: "10.0.0.1" local_asn: 65000 -listen_endpoints { - address: "127.0.0.1" - port: 179 + +# Where to accept BGP connections. Port 179 needs privileges; pick another +# port to run unprivileged against a peer that dials a custom port. +listen_endpoints { address: "127.0.0.1" port: 179 } +listen_endpoints { address: "::1" port: 179 } + +# A peer we connect to and accept connections from. Connections from any +# other address are closed unread, unless accept_unconfigured_peers is set, +# which is for a closed test network only. +peers { + name: "r1" + endpoint { address: "127.0.0.1" port: 1791 } + remote_asn: 65001 + hold_time_seconds: 30 + # Offer only these families to the peer. Left out, all are offered. + address_families: IPV4_UNICAST + address_families: IPV6_UNICAST + # Take several routes for a prefix from this peer, if it offers to send + # them (ADD-PATH). Also ADD_PATH_SEND and ADD_PATH_BOTH. Off by default. + add_path: ADD_PATH_RECEIVE } -listen_endpoints { - address: "[::1]" - port: 179 +connect_retry_seconds: 5 + +# Let a restarted peer replace a stale Established session immediately +# (RFC 4271 CollisionDetectEstablishedState). Default false. +collision_detect_established_state: false + +# Routes this router originates, announced to every peer. +originate { prefix: "10.100.0.0/16" } +originate { + prefix: "2001:db8:100::/48" + med: 50 + communities: "65000:100" + large_communities: "65000:1:2" } + +# Read-only status page, JSON API and metrics. It has no authentication: +# keep it on loopback or behind a reverse proxy. +http_endpoint { address: "127.0.0.1" port: 8080 }