diff --git a/Cargo.lock b/Cargo.lock index a6310aa6..7a245578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -307,7 +307,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "rdb", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -334,7 +334,7 @@ dependencies = [ "rand 0.8.5", "rdb", "rhai", - "schemars", + "schemars 0.8.22", "serde", "serial_test", "slog", @@ -346,7 +346,7 @@ dependencies = [ [[package]] name = "bhyve_api" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "bhyve_api_sys", "libc", @@ -356,7 +356,7 @@ dependencies = [ [[package]] name = "bhyve_api_sys" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "libc", "strum 0.26.3", @@ -428,7 +428,7 @@ dependencies = [ [[package]] name = "bootstore" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "bytes", "camino", @@ -689,7 +689,16 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clickhouse-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "clickhouse-admin-types-versions", + "omicron-workspace-hack", +] + +[[package]] +name = "clickhouse-admin-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "atomicwrites", @@ -702,7 +711,7 @@ dependencies = [ "itertools 0.14.0", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -717,7 +726,7 @@ dependencies = [ "camino", "clap", "derive_more", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 1.0.69", @@ -745,13 +754,23 @@ dependencies = [ [[package]] name = "cockroach-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "cockroach-admin-types-versions", + "omicron-workspace-hack", + "serde", +] + +[[package]] +name = "cockroach-admin-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "chrono", "csv", - "omicron-common", + "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "thiserror 2.0.17", ] @@ -781,7 +800,7 @@ dependencies = [ "oximeter", "oxnet", "rand 0.9.2", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -968,7 +987,7 @@ source = "git+https://github.com/oxidecomputer/crucible?rev=7103cd3a3d7b0112d294 dependencies = [ "base64 0.22.1", "crucible-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -1169,7 +1188,7 @@ dependencies = [ "oximeter-producer", "oxnet", "pretty_assertions", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sled", @@ -1201,7 +1220,7 @@ dependencies = [ "dropshot-api-manager-types", "mg-common", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "uuid", ] @@ -1212,7 +1231,7 @@ version = "0.1.0" dependencies = [ "mg-common", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "serde_repr", ] @@ -1310,6 +1329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1431,7 +1451,7 @@ dependencies = [ "progenitor 0.11.2", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -1447,7 +1467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9ba64b39d5fd68e09169e63c8e82b7a50c9b6082f2c44f52db2a11e3b9d7dd4" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.12.1", "openapiv3", "regex", "serde", @@ -1475,14 +1495,14 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap", + "indexmap 2.12.1", "multer", "openapiv3", "paste", "percent-encoding", "rustls 0.22.4", "rustls-pemfile", - "schemars", + "schemars 0.8.22", "scopeguard", "semver 1.0.27", "serde", @@ -1678,12 +1698,12 @@ dependencies = [ [[package]] name = "ereport-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "dropshot", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.17", @@ -1974,7 +1994,7 @@ dependencies = [ [[package]] name = "gateway-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "base64 0.22.1", "chrono", @@ -1987,7 +2007,7 @@ dependencies = [ "progenitor 0.10.0", "rand 0.9.2", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -2016,7 +2036,7 @@ dependencies = [ [[package]] name = "gateway-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "gateway-types-versions", "omicron-workspace-hack", @@ -2025,7 +2045,7 @@ dependencies = [ [[package]] name = "gateway-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "daft", "dropshot", @@ -2033,7 +2053,7 @@ dependencies = [ "hex", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "thiserror 2.0.17", "tufaceous-artifact", @@ -2088,6 +2108,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gfss" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "digest", + "omicron-workspace-hack", + "rand 0.9.2", + "schemars 0.8.22", + "secrecy", + "serde", + "subtle", + "thiserror 2.0.17", + "zeroize", +] + [[package]] name = "glob" version = "0.3.3" @@ -2152,7 +2188,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -2179,6 +2215,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2630,7 +2672,7 @@ dependencies = [ "hashbrown 0.16.1", "ref-cast", "rustc-hash", - "schemars", + "schemars 0.8.22", "serde_core", "serde_json", ] @@ -2691,7 +2733,7 @@ dependencies = [ [[package]] name = "illumos-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "async-trait", @@ -2700,6 +2742,7 @@ dependencies = [ "camino", "camino-tempfile", "cfg-if", + "chrono", "crucible-smf", "debug-ignore", "dropshot", @@ -2717,10 +2760,12 @@ dependencies = [ "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80)", "oxlog", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "slog", + "slog-async", "slog-error-chain", + "slog-term", "smf 0.2.3", "thiserror 2.0.17", "tofino", @@ -2736,6 +2781,17 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3" +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -2821,7 +2877,7 @@ dependencies = [ [[package]] name = "internal-dns-resolver" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "futures", "hickory-proto 0.25.2", @@ -2839,18 +2895,32 @@ dependencies = [ [[package]] name = "internal-dns-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "chrono", + "internal-dns-types-versions", "omicron-common", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "strum 0.27.2", ] +[[package]] +name = "internal-dns-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "anyhow", + "chrono", + "omicron-common", + "omicron-workspace-hack", + "schemars 0.8.22", + "serde", +] + [[package]] name = "ipconfig" version = "0.3.2" @@ -2875,7 +2945,7 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", ] @@ -3316,7 +3386,7 @@ dependencies = [ "progenitor 0.11.2", "rdb-types 0.1.0", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3334,7 +3404,7 @@ dependencies = [ "progenitor 0.11.2", "rdb-types 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a)", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3351,7 +3421,7 @@ dependencies = [ "dropshot", "dropshot-api-manager-types", "rdb", - "schemars", + "schemars 0.8.22", "serde", ] @@ -3368,7 +3438,7 @@ dependencies = [ "oximeter", "oximeter-producer", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -3570,7 +3640,7 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -3602,7 +3672,7 @@ dependencies = [ [[package]] name = "nexus-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "chrono", "futures", @@ -3615,7 +3685,7 @@ dependencies = [ "progenitor 0.10.0", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3625,7 +3695,7 @@ dependencies = [ [[package]] name = "nexus-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "api_identity", @@ -3650,6 +3720,7 @@ dependencies = [ "illumos-utils", "indent_write", "internal-dns-types", + "ipnet", "ipnetwork", "itertools 0.14.0", "newtype-uuid", @@ -3664,11 +3735,12 @@ dependencies = [ "oxql-types", "parse-display", "regex", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", "serde_with", + "sled-agent-types", "sled-agent-types-versions", "sled-hardware-types", "slog", @@ -3682,6 +3754,8 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tough", + "trust-quorum-protocol", + "trust-quorum-types", "tufaceous-artifact", "unicode-width 0.1.14", "update-engine", @@ -3919,7 +3993,7 @@ dependencies = [ "rand 0.9.2", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_human_bytes", @@ -3937,12 +4011,12 @@ dependencies = [ [[package]] name = "omicron-passwords" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "argon2", "omicron-workspace-hack", "rand 0.9.2", - "schemars", + "schemars 0.8.22", "secrecy", "serde", "serde_with", @@ -3952,7 +4026,7 @@ dependencies = [ [[package]] name = "omicron-rpaths" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "omicron-workspace-hack", ] @@ -3966,7 +4040,7 @@ dependencies = [ "newtype-uuid", "newtype-uuid-macros", "paste", - "schemars", + "schemars 0.8.22", ] [[package]] @@ -4035,7 +4109,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde", "serde_json", ] @@ -4237,7 +4311,7 @@ dependencies = [ [[package]] name = "oximeter-db" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "async-recursion", @@ -4256,7 +4330,7 @@ dependencies = [ "gethostname", "highway", "iana-time-zone", - "indexmap", + "indexmap 2.12.1", "libc", "nom", "num", @@ -4270,7 +4344,7 @@ dependencies = [ "quote", "regex", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -4301,7 +4375,7 @@ dependencies = [ [[package]] name = "oximeter-producer" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "chrono", "dropshot", @@ -4311,7 +4385,7 @@ dependencies = [ "omicron-common", "omicron-workspace-hack", "oximeter", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-dtrace", @@ -4334,7 +4408,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "serde", "slog-error-chain", "syn 2.0.111", @@ -4368,7 +4442,7 @@ dependencies = [ "oximeter-types-versions", "parse-display", "regex", - "schemars", + "schemars 0.8.22", "serde", "strum 0.27.2", "thiserror 2.0.17", @@ -4383,7 +4457,7 @@ dependencies = [ "chrono", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "uuid", ] @@ -4391,7 +4465,7 @@ dependencies = [ [[package]] name = "oxlog" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "camino", @@ -4412,7 +4486,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dc6fb07ecd6d2a17ff1431bc5b3ce11036c0b6dd93a3c4904db5b910817b162" dependencies = [ "ipnetwork", - "schemars", + "schemars 0.8.22", "serde", "serde_json", ] @@ -4420,7 +4494,7 @@ dependencies = [ [[package]] name = "oxql-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "chrono", @@ -4428,7 +4502,7 @@ dependencies = [ "num", "omicron-workspace-hack", "oximeter-types", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -4612,7 +4686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap", + "indexmap 2.12.1", "serde", "serde_derive", ] @@ -4625,7 +4699,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.12.1", "serde", ] @@ -4702,6 +4776,11 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "poptrie" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/poptrie?branch=main#5bf62f6b889c61e0608d8463ed11da28e130cb34" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4915,12 +4994,12 @@ checksum = "b17e5363daa50bf1cccfade6b0fb970d2278758fd5cfa9ab69f25028e4b1afa3" dependencies = [ "heck 0.5.0", "http", - "indexmap", + "indexmap 2.12.1", "openapiv3", "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "syn 2.0.111", @@ -4937,12 +5016,12 @@ checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" dependencies = [ "heck 0.5.0", "http", - "indexmap", + "indexmap 2.12.1", "openapiv3", "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "syn 2.0.111", @@ -4961,7 +5040,7 @@ dependencies = [ "proc-macro2", "progenitor-impl 0.10.0", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", @@ -4979,7 +5058,7 @@ dependencies = [ "proc-macro2", "progenitor-impl 0.11.2", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", @@ -5001,7 +5080,7 @@ dependencies = [ "propolis_api_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66)", "rand 0.9.2", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -5014,11 +5093,11 @@ dependencies = [ [[package]] name = "propolis_api_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "crucible-client-types", - "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66)", - "schemars", + "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440)", + "schemars 0.8.22", "serde", "thiserror 1.0.69", "uuid", @@ -5027,11 +5106,11 @@ dependencies = [ [[package]] name = "propolis_api_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" dependencies = [ "crucible-client-types", - "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9)", - "schemars", + "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66)", + "schemars 0.8.22", "serde", "thiserror 1.0.69", "uuid", @@ -5040,18 +5119,18 @@ dependencies = [ [[package]] name = "propolis_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", ] [[package]] name = "propolis_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", ] @@ -5080,7 +5159,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/lldp#61479b6922f9112fbe1e722414d2b8055212cb12" dependencies = [ "anyhow", - "schemars", + "schemars 0.8.22", "serde", "thiserror 1.0.69", ] @@ -5280,10 +5359,12 @@ dependencies = [ "clap", "itertools 0.14.0", "mg-common", + "omicron-common", "oxnet", + "poptrie", "proptest", "rdb-types 0.1.0", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sled", @@ -5297,7 +5378,7 @@ version = "0.1.0" dependencies = [ "clap", "oxnet", - "schemars", + "schemars 0.8.22", "serde", ] @@ -5307,7 +5388,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a#0df320d42b356e689a3c7a7600eec9b16770237a" dependencies = [ "oxnet", - "schemars", + "schemars 0.8.22", "serde", ] @@ -5702,6 +5783,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -5945,6 +6050,11 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.2.0", "serde_core", "serde_json", "serde_with_macros", @@ -5969,7 +6079,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.12.1", "itoa", "ryu", "serde", @@ -6100,10 +6210,42 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "sled-agent-types" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "anyhow", + "async-trait", + "bootstore", + "camino", + "chrono", + "daft", + "iddqd", + "omicron-common", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "oxnet", + "schemars 0.8.22", + "serde", + "serde_human_bytes", + "serde_json", + "sled-agent-types-versions", + "sled-hardware-types", + "slog", + "slog-error-chain", + "strum 0.27.2", + "swrite", + "thiserror 2.0.17", + "toml 0.8.23", + "tufaceous-artifact", + "uuid", +] + [[package]] name = "sled-agent-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "async-trait", "bootstore", @@ -6118,15 +6260,17 @@ dependencies = [ "omicron-uuid-kinds", "omicron-workspace-hack", "oxnet", - "propolis_api_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9)", - "schemars", + "propolis_api_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440)", + "schemars 0.8.22", "serde", "serde_json", + "serde_with", "sha3", "sled-hardware-types", "slog", "strum 0.27.2", "thiserror 2.0.17", + "trust-quorum-types-versions", "tufaceous-artifact", "uuid", ] @@ -6134,13 +6278,16 @@ dependencies = [ [[package]] name = "sled-hardware-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ + "daft", "illumos-utils", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", + "slog", + "thiserror 2.0.17", ] [[package]] @@ -6427,7 +6574,7 @@ dependencies = [ "lazy_static", "newtype_derive", "petgraph 0.6.5", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -7047,7 +7194,7 @@ version = "0.9.9+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb5238e643fc34a1d5d7e753e1532a91912d74b63b92b3ea51fde8d1b7bc79dd" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde_core", "serde_spanned 1.0.4", "toml_datetime 0.7.4+spec-1.0.0", @@ -7080,7 +7227,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7093,7 +7240,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7249,7 +7396,7 @@ dependencies = [ "hubpack", "itertools 0.14.0", "nix", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -7268,7 +7415,7 @@ name = "transceiver-decode" version = "0.1.0" source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#7cd1a05eff3b1a480ed0d573124f6784d24999b0" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", "static_assertions", "thiserror 2.0.17", @@ -7283,11 +7430,77 @@ dependencies = [ "bitflags 2.10.0", "clap", "hubpack", - "schemars", + "schemars 0.8.22", "serde", "thiserror 2.0.17", ] +[[package]] +name = "trust-quorum-protocol" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "bootstore", + "bytes", + "camino", + "chacha20poly1305", + "ciborium", + "daft", + "derive_more", + "gfss", + "hex", + "hkdf", + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "rand 0.9.2", + "secrecy", + "serde", + "serde_with", + "sha3", + "sled-agent-types", + "sled-hardware-types", + "slog", + "slog-error-chain", + "static_assertions", + "subtle", + "thiserror 2.0.17", + "trust-quorum-types", + "uuid", + "zeroize", +] + +[[package]] +name = "trust-quorum-types" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "omicron-workspace-hack", + "trust-quorum-types-versions", +] + +[[package]] +name = "trust-quorum-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" +dependencies = [ + "daft", + "derive_more", + "gfss", + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "rand 0.9.2", + "schemars 0.8.22", + "serde", + "serde_human_bytes", + "serde_with", + "sled-hardware-types", + "slog", + "slog-error-chain", + "thiserror 2.0.17", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7302,7 +7515,7 @@ dependencies = [ "daft", "hex", "proptest", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_human_bytes", @@ -7369,7 +7582,7 @@ dependencies = [ "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", @@ -7386,7 +7599,7 @@ checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", @@ -7489,7 +7702,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "update-engine" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#a4e9c7a1df3c95349a79f6691f614e1c4e3db98b" dependencies = [ "anyhow", "cancel-safe-futures", @@ -7499,13 +7712,13 @@ dependencies = [ "either", "futures", "indent_write", - "indexmap", + "indexmap 2.12.1", "libsw", "linear-map", "omicron-workspace-hack", "owo-colors", "petgraph 0.8.3", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_with", diff --git a/Cargo.toml b/Cargo.toml index 61679d87..3a84bd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -100,10 +100,11 @@ mg-common = { path = "mg-common" } rdb-types = { path = "rdb-types" } chrono = { version = "0.4.42", features = ["serde"] } oxide-tokio-rt = "0.1.2" -oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} -oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} +oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } +oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } oxnet = { version = "0.1.4", default-features = false, features = ["schemars", "serde"] } -omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} +omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } +poptrie = { git = "https://github.com/oxidecomputer/poptrie", branch = "main" } uuid = { version = "1.8", features = ["serde", "v4"] } smf = { git = "https://github.com/illumos/smf-rs", branch = "main" } libc = "0.2" diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 69721dab..583fb8dd 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -23,7 +23,7 @@ use dropshot::{ use dropshot_api_manager_types::api_versions; use rdb::{ BfdPeerConfig, Path as RdbPath, Prefix, Prefix4, Prefix6, StaticRouteKey, - types::{AddressFamily, ProtocolFilter}, + types::{AddressFamily, MulticastRoute, MulticastRouteKey, ProtocolFilter}, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -40,6 +40,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (4, MULTICAST_SUPPORT), (3, SWITCH_IDENTIFIERS), (2, IPV6_BASIC), (1, INITIAL), @@ -377,6 +378,72 @@ pub trait MgAdminApi { async fn switch_identifiers( ctx: RequestContext, ) -> Result, HttpError>; + + // ========================= MRIB: Multicast ============================== + // + // Multicast routing is API-driven with Omicron as the source of truth. + // Static route endpoints (add/delete) are intended for Nexus RPW use. + // Direct operator configuration should go through the Oxide API to + // maintain consistency with the control plane's view of group membership. + + /// Get imported multicast routes (`mrib_in`). + /// + /// When `group` is provided, returns a specific route. + /// When `group` is omitted, returns all routes (with optional filters). + #[endpoint { method = GET, path = "/mrib/status/imported", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn get_mrib_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Get selected multicast routes (`mrib_loc`, RPF-validated). + /// + /// When `group` is provided, returns a specific route. + /// When `group` is omitted, returns all routes (with optional filters). + #[endpoint { method = GET, path = "/mrib/status/selected", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn get_mrib_selected( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Add static multicast routes. + /// + /// This endpoint is intended for Nexus RPW use. Operators should + /// configure multicast group membership through the Oxide API. + #[endpoint { method = PUT, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_add_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// Remove static multicast routes. + /// + /// This endpoint is intended for Nexus RPW use. Operators should + /// configure multicast group membership through the Oxide API. + #[endpoint { method = DELETE, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_remove_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// List all static multicast routes. + #[endpoint { method = GET, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_list_mcast_routes( + rqctx: RequestContext, + ) -> Result>, HttpError>; + + /// Get the current RPF rebuild interval. + #[endpoint { method = GET, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Set the RPF rebuild interval. + #[endpoint { method = POST, path = "/mrib/config/rpf/rebuild-interval", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; } /// Identifiers for a switch. @@ -577,6 +644,99 @@ impl From for StaticRouteKey { } } +// ========================= MRIB Types ============================== + +/// Input for adding static multicast routes. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct StaticMulticastRouteInput { + /// The multicast route key (S,G) or (*,G). + pub key: MulticastRouteKey, + /// Underlay unicast nexthops for multicast replication. + /// + /// Unicast IPv6 addresses where encapsulated overlay multicast traffic + /// is forwarded. These are sled underlay addresses hosting VMs subscribed + /// to the multicast group. Forms the outgoing interface list (OIL). + pub underlay_nexthops: Vec, + /// Underlay multicast group address (ff04::X). + /// + /// Admin-local scoped IPv6 multicast address corresponding to the overlay + /// multicast group. 1:1 mapped and always derived from the overlay + /// multicast group in Omicron. + pub underlay_group: Ipv6Addr, +} + +/// Request body for adding static multicast routes to the MRIB. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribAddStaticRequest { + /// List of static multicast routes to add. + pub routes: Vec, +} + +/// Request body for deleting static multicast routes from the MRIB. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribDeleteStaticRequest { + /// List of route keys to delete. + pub keys: Vec, +} + +/// Response containing the current RPF rebuild interval. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribRpfRebuildIntervalResponse { + /// Minimum interval between RPF cache rebuilds in milliseconds. + pub interval_ms: u64, +} + +/// Request body for setting the RPF rebuild interval. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribRpfRebuildIntervalRequest { + /// Minimum interval between RPF cache rebuilds in milliseconds. + pub interval_ms: u64, +} + +/// Filter for multicast route origin. +#[derive( + Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq, +)] +#[serde(rename_all = "snake_case")] +pub enum RouteOriginFilter { + /// Static routes only (operator configured). + Static, + /// Dynamic routes only (learned via IGMP, MLD, etc.). + Dynamic, +} + +/// Query parameters for MRIB routes. +/// +/// When `group` is provided, looks up a specific route. +/// When `group` is omitted, lists all routes (with optional filters). +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribQuery { + /// Multicast group address. If provided, returns a specific route. + /// If omitted, returns all routes matching the filters. + #[serde(default)] + pub group: Option, + /// Source address (`None` for (*,G) routes). Only used when `group` is set. + #[serde(default)] + pub source: Option, + /// VNI (defaults to 77 for fleet-scoped multicast). + /// Only used when `group` is set. + #[serde(default = "default_multicast_vni")] + pub vni: u32, + /// Filter by address family. Only used when listing all routes. + #[serde(default)] + pub address_family: Option, + /// Filter by route origin ("static" or "dynamic"). + /// Only used when listing all routes. + #[serde(default)] + pub route_origin: Option, +} + +fn default_multicast_vni() -> u32 { + rdb::DEFAULT_MULTICAST_VNI +} + +// ========================= RIB Types ============================== + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct RibQuery { /// Filter by address family (None means all families) diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index b0f027de..0a189943 100644 --- a/mg-common/src/test.rs +++ b/mg-common/src/test.rs @@ -13,8 +13,10 @@ use std::os::unix::io::AsRawFd; use std::process::Command; use std::sync::{Arc, Mutex}; -pub const DEFAULT_INTERVAL: u64 = 1; -pub const DEFAULT_ITERATIONS: u64 = 30; +/// Default polling interval in milliseconds for wait_for macros. +pub const DEFAULT_INTERVAL_MS: u64 = 10; +/// Default number of iterations for wait_for macros (30 seconds total). +pub const DEFAULT_ITERATIONS: u64 = 3000; // Note: get_test_db has been moved to rdb::test::get_test_db // to break the circular dependency between mg-common and rdb. @@ -37,18 +39,25 @@ impl FileLockExt for File { } } +/// Wait for two expressions to be equal, polling at the given interval. +/// +/// # Arguments +/// - `$lhs`, `$rhs`: Expressions to compare +/// - `$interval_ms`: Polling interval in milliseconds +/// - `$count`: Maximum number of iterations +/// - `$msg`: Optional panic message #[macro_export] macro_rules! wait_for_eq { - ($lhs:expr, $rhs:expr, $period:expr, $count:expr, $msg:tt) => { - wait_for!($lhs == $rhs, $period, $count, $msg); + ($lhs:expr, $rhs:expr, $interval_ms:expr, $count:expr, $msg:tt) => { + wait_for!($lhs == $rhs, $interval_ms, $count, $msg); }; - ($lhs:expr, $rhs:expr, $period:expr, $count:expr) => { - wait_for!($lhs == $rhs, $period, $count); + ($lhs:expr, $rhs:expr, $interval_ms:expr, $count:expr) => { + wait_for!($lhs == $rhs, $interval_ms, $count); }; ($lhs:expr, $rhs:expr, $msg:tt) => { wait_for!( $lhs == $rhs, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS, $msg ); @@ -56,24 +65,31 @@ macro_rules! wait_for_eq { ($lhs:expr, $rhs:expr) => { wait_for!( $lhs == $rhs, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS ); }; } +/// Wait for two expressions to be not equal, polling at the given interval. +/// +/// # Arguments +/// - `$lhs`, `$rhs`: Expressions to compare +/// - `$interval_ms`: Polling interval in milliseconds +/// - `$count`: Maximum number of iterations +/// - `$msg`: Optional panic message #[macro_export] macro_rules! wait_for_neq { - ($lhs:expr, $rhs:expr, $period:expr, $count:expr, $msg:tt) => { - wait_for!($lhs != $rhs, $period, $count, $msg); + ($lhs:expr, $rhs:expr, $interval_ms:expr, $count:expr, $msg:tt) => { + wait_for!($lhs != $rhs, $interval_ms, $count, $msg); }; - ($lhs:expr, $rhs:expr, $period:expr, $count:expr) => { - wait_for!($lhs != $rhs, $period, $count); + ($lhs:expr, $rhs:expr, $interval_ms:expr, $count:expr) => { + wait_for!($lhs != $rhs, $interval_ms, $count); }; ($lhs:expr, $rhs:expr, $msg:tt) => { wait_for!( $lhs != $rhs, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS, $msg ); @@ -81,35 +97,51 @@ macro_rules! wait_for_neq { ($lhs:expr, $rhs:expr) => { wait_for!( $lhs != $rhs, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS ); }; } +/// Wait for a condition to become true, polling at the given interval. +/// +/// # Arguments +/// - `$cond`: Condition expression to poll +/// - `$interval_ms`: Polling interval in milliseconds +/// - `$count`: Maximum number of iterations +/// - `$msg`: Optional panic message +/// +/// # Example +/// ```ignore +/// // Wait up to 5 seconds (10ms × 500) for condition +/// wait_for!(some_condition(), 10, 500, "condition not met"); +/// +/// // Use defaults (10ms × 3000 = 30 seconds) +/// wait_for!(some_condition()); +/// ``` #[macro_export] macro_rules! wait_for { - ($cond:expr, $period:expr, $count:expr, $msg:tt) => { + ($cond:expr, $interval_ms:expr, $count:expr, $msg:tt) => { let mut ok = false; for _ in 0..$count { if $cond { ok = true; break; } - std::thread::sleep(std::time::Duration::from_secs($period)); + std::thread::sleep(std::time::Duration::from_millis($interval_ms)); } if !ok { assert!($cond, $msg); } }; - ($cond:expr, $period:expr, $count:expr) => { + ($cond:expr, $interval_ms:expr, $count:expr) => { let mut ok = false; for _ in 0..$count { if $cond { ok = true; break; } - std::thread::sleep(std::time::Duration::from_secs($period)); + std::thread::sleep(std::time::Duration::from_millis($interval_ms)); } if !ok { assert!($cond); @@ -118,7 +150,7 @@ macro_rules! wait_for { ($cond:expr, $msg:tt) => { wait_for!( $cond, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS, $msg ); @@ -126,7 +158,7 @@ macro_rules! wait_for { ($cond:expr) => { wait_for!( $cond, - mg_common::test::DEFAULT_INTERVAL, + mg_common::test::DEFAULT_INTERVAL_MS, mg_common::test::DEFAULT_ITERATIONS ); }; @@ -703,7 +735,7 @@ pub fn dump_thread_stacks() -> Result { { // Use gdb to attach and dump thread backtraces let output = Command::new("gdb") - .args(&[ + .args([ "-batch", "-ex", "thread apply all bt", @@ -713,13 +745,10 @@ pub fn dump_thread_stacks() -> Result { .output()?; if !output.status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!( - "gdb failed: {}", - String::from_utf8_lossy(&output.stderr) - ), - )); + return Err(std::io::Error::other(format!( + "gdb failed: {}", + String::from_utf8_lossy(&output.stderr) + ))); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) diff --git a/mgadm/src/main.rs b/mgadm/src/main.rs index 9229dbb7..7c178099 100644 --- a/mgadm/src/main.rs +++ b/mgadm/src/main.rs @@ -14,6 +14,7 @@ use std::net::{IpAddr, SocketAddr}; mod bfd; mod bgp; +mod mrib; mod rib; mod static_routing; @@ -55,6 +56,10 @@ enum Commands { /// RIB configuration commands. #[command(subcommand)] Rib(rib::Commands), + + /// Multicast RIB management commands. + #[command(subcommand)] + Mrib(mrib::Commands), } fn main() -> Result<()> { @@ -77,6 +82,7 @@ async fn run() -> Result<()> { } Commands::Bfd(command) => bfd::commands(command, client).await?, Commands::Rib(command) => rib::commands(command, client).await?, + Commands::Mrib(command) => mrib::commands(command, client).await?, } Ok(()) } diff --git a/mgadm/src/mrib.rs b/mgadm/src/mrib.rs new file mode 100644 index 00000000..36a37e5c --- /dev/null +++ b/mgadm/src/mrib.rs @@ -0,0 +1,467 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! MRIB (Multicast RIB) administration commands. +//! +//! This module provides read-only inspection of multicast routing state. +//! Omicron is the source of truth for multicast group membership and +//! programs the MRIB via the mg-api. Administrative writes are not +//! exposed here to avoid conflicts with Omicron-managed state. + +use std::net::IpAddr; + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use mg_admin_client::Client; +use mg_admin_client::types::{ + MribRpfRebuildIntervalRequest, MulticastRoute, MulticastRouteKey, + RouteOriginFilter, +}; +use rdb::types::{AddressFamily, DEFAULT_MULTICAST_VNI}; + +fn parse_route_origin(s: &str) -> Result { + match s.to_lowercase().as_str() { + "static" => Ok(RouteOriginFilter::Static), + "dynamic" => Ok(RouteOriginFilter::Dynamic), + _ => Err(format!( + "invalid origin: {s} (expected 'static' or 'dynamic')" + )), + } +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// View MRIB state. + Status(StatusCommand), + + /// RPF (Reverse Path Forwarding) table configuration and lookup. + Rpf(RpfCommand), +} + +#[derive(Debug, Args)] +pub struct StatusCommand { + #[command(subcommand)] + command: StatusCmd, +} + +#[derive(Subcommand, Debug)] +pub enum StatusCmd { + /// Get imported multicast routes (`mrib_in`). + /// + /// Lists all routes, or gets a specific route with `-g`. + /// + /// Usage: `mrib status imported [ipv4|ipv6] [-g group] [-s source] [-v vni]` + Imported { + /// Address family to filter by. + #[arg(value_enum)] + address_family: Option, + + /// Multicast group address (if omitted, lists all routes). + #[arg(short, long)] + group: Option, + + /// Source address (omit for any-source (*,G)). + #[arg(short, long)] + source: Option, + + /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). + #[arg(short, long, default_value_t = DEFAULT_MULTICAST_VNI)] + vni: u32, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_parser = parse_route_origin)] + origin: Option, + }, + + /// Get selected multicast routes (`mrib_loc`, RPF-validated). + /// + /// Lists all routes, or gets a specific route with `-g`. + /// + /// Usage: `mrib status selected [ipv4|ipv6] [-g group] [-s source] [-v vni]` + Selected { + /// Address family to filter by. + #[arg(value_enum)] + address_family: Option, + + /// Multicast group address (if omitted, lists all routes). + #[arg(short, long)] + group: Option, + + /// Source address (omit for any-source (*,G)). + #[arg(short, long)] + source: Option, + + /// VNI (defaults to DEFAULT_MULTICAST_VNI for fleet-scoped multicast). + #[arg(short, long, default_value_t = DEFAULT_MULTICAST_VNI)] + vni: u32, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_parser = parse_route_origin)] + origin: Option, + }, +} + +#[derive(Debug, Args)] +pub struct RpfCommand { + #[command(subcommand)] + command: RpfCmd, +} + +#[derive(Subcommand, Debug)] +pub enum RpfCmd { + /// Get RPF rebuild interval. + GetInterval, + + /// Set RPF rebuild interval. + SetInterval { + /// Rebuild interval in milliseconds + interval_ms: u64, + }, +} + +pub async fn commands(command: Commands, c: Client) -> Result<()> { + match command { + Commands::Status(status_cmd) => match status_cmd.command { + StatusCmd::Imported { + group, + source, + vni, + address_family, + origin, + } => { + if let Some(g) = group { + get_route(c, g, source, vni).await? + } else { + get_imported(c, address_family, origin).await? + } + } + StatusCmd::Selected { + group, + source, + vni, + address_family, + origin, + } => { + if let Some(g) = group { + get_route_selected(c, g, source, vni).await? + } else { + get_selected(c, address_family, origin).await? + } + } + }, + Commands::Rpf(rpf_cmd) => match rpf_cmd.command { + RpfCmd::GetInterval => get_rpf_interval(c).await?, + RpfCmd::SetInterval { interval_ms } => { + set_rpf_interval(c, interval_ms).await? + } + }, + } + Ok(()) +} + +async fn get_imported( + c: Client, + address_family: Option, + origin: Option, +) -> Result<()> { + let routes = c + .get_mrib_imported(address_family.as_ref(), None, origin, None, None) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_selected( + c: Client, + address_family: Option, + origin: Option, +) -> Result<()> { + let routes = c + .get_mrib_selected(address_family.as_ref(), None, origin, None, None) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_route( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let routes = c + .get_mrib_imported(None, Some(&group), None, source.as_ref(), Some(vni)) + .await? + .into_inner(); + if let Some(route) = routes.first() { + println!("{route:#?}"); + } else { + anyhow::bail!("route not found"); + } + Ok(()) +} + +async fn get_route_selected( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let routes = c + .get_mrib_selected(None, Some(&group), None, source.as_ref(), Some(vni)) + .await? + .into_inner(); + if let Some(route) = routes.first() { + println!("{route:#?}"); + } else { + anyhow::bail!("route not found in mrib_loc"); + } + Ok(()) +} + +async fn get_rpf_interval(c: Client) -> Result<()> { + let result = c.read_mrib_rpf_rebuild_interval().await?.into_inner(); + println!("RPF rebuild interval: {}ms", result.interval_ms); + Ok(()) +} + +async fn set_rpf_interval(c: Client, interval_ms: u64) -> Result<()> { + c.update_mrib_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { + interval_ms, + }) + .await?; + println!("Updated RPF rebuild interval to: {interval_ms}ms"); + Ok(()) +} + +fn print_routes(routes: &[MulticastRoute]) { + if routes.is_empty() { + println!("No multicast routes"); + return; + } + for route in routes { + let (source_str, group_str, vni) = match &route.key { + MulticastRouteKey::V4(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + MulticastRouteKey::V6(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + }; + println!( + "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} nexthops={} source={:?}", + route.underlay_group, + route.rpf_neighbor, + route.underlay_nexthops.len(), + route.source, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::net::{Ipv4Addr, Ipv6Addr}; + + // Wrapper to test subcommand parsing + #[derive(Parser, Debug)] + struct TestCli { + #[command(subcommand)] + command: Commands, + } + + #[test] + fn test_status_imported_specific_route() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "imported", + "-g", + "225.1.2.3", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))) + ); + assert_eq!(source, None); + assert_eq!(vni, DEFAULT_MULTICAST_VNI); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_specific_route_all_flags() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "imported", + "-g", + "225.1.2.3", + "-s", + "10.0.0.1", + "-v", + "100", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))) + ); + assert_eq!( + source, + Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))) + ); + assert_eq!(vni, 100); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_selected_specific_route_ipv6() { + let cli = TestCli::try_parse_from([ + "test", + "status", + "selected", + "--group", + "ff0e::1", + "--source", + "2001:db8::1", + "--vni", + "42", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Selected { + group, source, vni, .. + } => { + assert_eq!( + group, + Some(IpAddr::V6(Ipv6Addr::new( + 0xff0e, 0, 0, 0, 0, 0, 0, 1 + ))) + ); + assert_eq!( + source, + Some(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + ))) + ); + assert_eq!(vni, 42); + } + _ => panic!("expected Selected"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_list_with_af() { + let cli = + TestCli::try_parse_from(["test", "status", "imported", "ipv4"]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { + group, + address_family, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, Some(AddressFamily::Ipv4)); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_list_with_origin() { + let cli = TestCli::try_parse_from([ + "test", "status", "imported", "--origin", "dynamic", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { group, origin, .. } => { + assert_eq!(group, None); + assert_eq!(origin, Some(RouteOriginFilter::Dynamic)); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_selected_list_all() { + let cli = + TestCli::try_parse_from(["test", "status", "selected"]).unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Selected { + group, + address_family, + origin, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, None); + assert_eq!(origin, None); + } + _ => panic!("expected Selected"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_rpf_set_interval() { + let cli = + TestCli::try_parse_from(["test", "rpf", "set-interval", "500"]) + .unwrap(); + + match cli.command { + Commands::Rpf(cmd) => match cmd.command { + RpfCmd::SetInterval { interval_ms } => { + assert_eq!(interval_ms, 500); + } + _ => panic!("expected SetInterval"), + }, + _ => panic!("expected Rpf command"), + } + } +} diff --git a/mgadm/src/static_routing.rs b/mgadm/src/static_routing.rs index 761f187e..701a6ed7 100644 --- a/mgadm/src/static_routing.rs +++ b/mgadm/src/static_routing.rs @@ -11,12 +11,16 @@ use std::net::{Ipv4Addr, Ipv6Addr}; #[derive(Subcommand, Debug)] pub enum Commands { + // Unicast static routes GetV4Routes, AddV4Route(StaticRoute4), RemoveV4Routes(StaticRoute4), GetV6Routes, AddV6Route(StaticRoute6), RemoveV6Routes(StaticRoute6), + + // Multicast static routes (read-only -> Omicron is source of truth) + GetMroutes, } #[derive(Debug, Args)] @@ -113,10 +117,40 @@ pub async fn commands(command: Commands, client: Client) -> Result<()> { }; client.static_remove_v6_route(&arg).await?; } + Commands::GetMroutes => { + let routes = client.static_list_mcast_routes().await?.into_inner(); + if routes.is_empty() { + println!("No static multicast routes"); + } else { + print_mroutes(&routes); + } + } } Ok(()) } +fn print_mroutes(routes: &[types::MulticastRoute]) { + for route in routes { + let (source_str, group_str, vni) = match &route.key { + types::MulticastRouteKey::V4(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + types::MulticastRouteKey::V6(k) => { + let src = k.source.map_or("*".to_string(), |s| s.to_string()); + let grp = k.group.to_string(); + (src, grp, k.vni) + } + }; + println!( + "({source_str},{group_str}) vni={vni} underlay={} nexthops={}", + route.underlay_group, + route.underlay_nexthops.len(), + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 63a0cd0f..eb9e2e36 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -use crate::{bfd_admin, bgp_admin, rib_admin, static_admin}; +use crate::{bfd_admin, bgp_admin, mrib_admin, rib_admin, static_admin}; use bfd_admin::BfdContext; use bgp::params::*; use bgp_admin::BgpContext; @@ -444,6 +444,56 @@ impl MgAdminApi for MgAdminApiImpl { ) -> Result, HttpError> { static_admin::switch_identifiers(ctx).await } + + async fn get_mrib_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::get_mrib_imported(rqctx, query).await + } + + async fn get_mrib_selected( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::get_mrib_selected(rqctx, query).await + } + + async fn static_add_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::static_add_mcast_route(rqctx, request).await + } + + async fn static_remove_mcast_route( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::static_remove_mcast_route(rqctx, request).await + } + + async fn static_list_mcast_routes( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + mrib_admin::static_list_mcast_routes(rqctx).await + } + + async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + ) -> Result, HttpError> { + mrib_admin::read_mrib_rpf_rebuild_interval(rqctx).await + } + + async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::update_mrib_rpf_rebuild_interval(rqctx, request).await + } } pub fn api_description() -> ApiDescription> { diff --git a/mgd/src/error.rs b/mgd/src/error.rs index c413fcde..99054ea6 100644 --- a/mgd/src/error.rs +++ b/mgd/src/error.rs @@ -25,7 +25,15 @@ pub enum Error { impl From for HttpError { fn from(value: Error) -> Self { match value { - Error::Db(_) => Self::for_internal_error(value.to_string()), + Error::Db(ref db_err) => match db_err { + rdb::error::Error::Validation(msg) => { + Self::for_bad_request(None, msg.clone()) + } + rdb::error::Error::NotFound(msg) => { + Self::for_not_found(None, msg.clone()) + } + _ => Self::for_internal_error(value.to_string()), + }, Error::Conflict(_) => Self::for_client_error_with_status( Some(value.to_string()), ClientErrorStatusCode::CONFLICT, diff --git a/mgd/src/main.rs b/mgd/src/main.rs index 96fac887..f55c5ff2 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -31,6 +31,7 @@ mod bfd_admin; mod bgp_admin; mod error; mod log; +mod mrib_admin; mod oxstats; mod rib_admin; mod signal; diff --git a/mgd/src/mrib_admin.rs b/mgd/src/mrib_admin.rs new file mode 100644 index 00000000..fa2b376f --- /dev/null +++ b/mgd/src/mrib_admin.rs @@ -0,0 +1,182 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::sync::Arc; +use std::time::Duration; + +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, RequestContext, TypedBody, +}; + +use mg_api::{ + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, + MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, + RouteOriginFilter, +}; +use rdb::types::{ + MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastRouteSource, +}; + +use crate::admin::HandlerContext; +use crate::error::Error; + +/// Convert [`RouteOriginFilter`] to the `static_only` parameter +/// used by [`rdb::Db::mrib_list`]. +fn origin_to_static_only(origin: Option) -> Option { + match origin { + None => None, + Some(RouteOriginFilter::Static) => Some(true), + Some(RouteOriginFilter::Dynamic) => Some(false), + } +} + +pub async fn get_mrib_imported( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // If group is provided, look up a specific route + if let Some(group_addr) = q.group { + let group = MulticastAddr::try_from(group_addr).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?; + let key = MulticastRouteKey::new(q.source, group, q.vni) + .map_err(|e| HttpError::for_bad_request(None, format!("{e}")))?; + let route = ctx.db.get_mcast_route(&key).ok_or_else(|| { + HttpError::for_not_found(None, format!("route {key} not found")) + })?; + return Ok(HttpResponseOk(vec![route])); + } + + // Otherwise, list all routes with filters + let routes = ctx.db.mrib_list( + q.address_family, + origin_to_static_only(q.route_origin), + false, // `mrib_in` + ); + Ok(HttpResponseOk(routes)) +} + +pub async fn get_mrib_selected( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // If group is provided, look up a specific route + if let Some(group_addr) = q.group { + let group = MulticastAddr::try_from(group_addr).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?; + let key = MulticastRouteKey::new(q.source, group, q.vni) + .map_err(|e| HttpError::for_bad_request(None, format!("{e}")))?; + let route = ctx.db.get_selected_mcast_route(&key).ok_or_else(|| { + HttpError::for_not_found( + None, + format!("route {key} not found in mrib_loc"), + ) + })?; + return Ok(HttpResponseOk(vec![route])); + } + + // Otherwise, list all routes with filters + let routes = ctx.db.mrib_list( + q.address_family, + origin_to_static_only(q.route_origin), + true, // `mrib_loc` + ); + Ok(HttpResponseOk(routes)) +} + +pub async fn static_add_mcast_route( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + + // Convert input to full `MulticastRoute` with timestamps + let routes: Vec = body + .routes + .into_iter() + .map(|input| { + let mut route = MulticastRoute::new( + input.key, + input.underlay_group, + MulticastRouteSource::Static, + ); + route.underlay_nexthops = + input.underlay_nexthops.into_iter().collect(); + route + }) + .collect(); + + // Validate routes before adding + for route in &routes { + route.validate().map_err(|e| { + HttpError::for_bad_request(None, format!("validation error: {e}")) + })?; + } + + ctx.db + .add_static_mcast_routes(&routes) + .map_err(Error::from)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn static_remove_mcast_route( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + ctx.db + .remove_static_mcast_routes(&body.keys) + .map_err(Error::from)?; + Ok(HttpResponseDeleted()) +} + +pub async fn static_list_mcast_routes( + rqctx: RequestContext>, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let routes = ctx.db.get_static_mcast_routes().map_err(Error::from)?; + Ok(HttpResponseOk(routes)) +} + +pub async fn read_mrib_rpf_rebuild_interval( + rqctx: RequestContext>, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let interval = ctx + .db + .get_mrib_rpf_rebuild_interval() + .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; + Ok(HttpResponseOk(MribRpfRebuildIntervalResponse { + interval_ms: interval.as_millis() as u64, + })) +} + +pub async fn update_mrib_rpf_rebuild_interval( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let ctx = rqctx.context(); + let body = request.into_inner(); + let interval = Duration::from_millis(body.interval_ms); + ctx.db + .set_mrib_rpf_rebuild_interval(interval) + .map_err(|e| HttpError::for_internal_error(format!("{e}")))?; + Ok(HttpResponseUpdatedNoContent()) +} diff --git a/mgd/src/static_admin.rs b/mgd/src/static_admin.rs index 098ac726..4e1dd0c2 100644 --- a/mgd/src/static_admin.rs +++ b/mgd/src/static_admin.rs @@ -10,7 +10,7 @@ use dropshot::{ }; use mg_api::{ AddStaticRoute4Request, AddStaticRoute6Request, DeleteStaticRoute4Request, - DeleteStaticRoute6Request, GetRibResult, + DeleteStaticRoute6Request, GetRibResult, SwitchIdentifiers, }; use rdb::{AddressFamily, Prefix, StaticRouteKey}; use std::{collections::BTreeMap, sync::Arc}; @@ -137,7 +137,7 @@ pub async fn static_list_v6_routes( pub(crate) async fn switch_identifiers( ctx: RequestContext>, -) -> Result, HttpError> { +) -> Result, HttpError> { let slot = ctx.context().db.slot(); - Ok(HttpResponseOk(mg_api::SwitchIdentifiers { slot })) + Ok(HttpResponseOk(SwitchIdentifiers { slot })) } diff --git a/openapi/mg-admin/mg-admin-4.0.0-8f51c3.json b/openapi/mg-admin/mg-admin-4.0.0-8f51c3.json new file mode 100644 index 00000000..1a0f2a0d --- /dev/null +++ b/openapi/mg-admin/mg-admin-4.0.0-8f51c3.json @@ -0,0 +1,4522 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Maghemite Admin", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "4.0.0" + }, + "paths": { + "/bfd/peers": { + "get": { + "summary": "Get all the peers and their associated BFD state. Peers are identified by IP", + "description": "address.", + "operationId": "get_bfd_peers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_BfdPeerInfo", + "type": "array", + "items": { + "$ref": "#/components/schemas/BfdPeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Add a new peer to the daemon. A session for the specified peer will start", + "description": "immediately.", + "operationId": "add_bfd_peer", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BfdPeerConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bfd/peers/{addr}": { + "delete": { + "summary": "Remove the specified peer from the daemon. The associated peer session will", + "description": "be stopped immediately.", + "operationId": "remove_bfd_peer", + "parameters": [ + { + "in": "path", + "name": "addr", + "description": "Address of the peer to remove.", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/clear/neighbor": { + "post": { + "operationId": "clear_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeighborResetRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/checker": { + "get": { + "operationId": "read_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_checker", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckerSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_checker", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbor": { + "get": { + "operationId": "read_neighbor", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_neighbor", + "parameters": [ + { + "in": "query", + "name": "addr", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbors": { + "get": { + "operationId": "read_neighbors", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Neighbor", + "type": "array", + "items": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin4": { + "get": { + "operationId": "read_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin4", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin4" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin4", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/origin6": { + "get": { + "operationId": "read_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_origin6", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Origin6" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_origin6", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/router": { + "get": { + "operationId": "read_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_router", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Router" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_router", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/routers": { + "get": { + "operationId": "read_routers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_Router", + "type": "array", + "items": { + "$ref": "#/components/schemas/Router" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/shaper": { + "get": { + "operationId": "read_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_shaper", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ShaperSource" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_shaper", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/fsm": { + "get": { + "operationId": "fsm_history", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsmHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/message": { + "get": { + "operationId": "message_history_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageHistoryResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/omicron/apply": { + "post": { + "operationId": "bgp_apply", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/exported": { + "get": { + "operationId": "get_exported", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AsnSelector" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Array_of_Prefix", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + } + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/status/neighbors": { + "get": { + "operationId": "get_neighbors_v2", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router to get imported prefixes from.", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_PeerInfo", + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PeerInfo" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/config/rpf/rebuild-interval": { + "get": { + "summary": "Get the current RPF rebuild interval.", + "operationId": "read_mrib_rpf_rebuild_interval", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribRpfRebuildIntervalResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "summary": "Set the RPF rebuild interval.", + "operationId": "update_mrib_rpf_rebuild_interval", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribRpfRebuildIntervalRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/status/imported": { + "get": { + "summary": "Get imported multicast routes (`mrib_in`).", + "description": "When `group` is provided, returns a specific route. When `group` is omitted, returns all routes (with optional filters).", + "operationId": "get_mrib_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family. Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "group", + "description": "Multicast group address. If provided, returns a specific route. If omitted, returns all routes matching the filters.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin (\"static\" or \"dynamic\"). Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to 77 for fleet-scoped multicast). Only used when `group` is set.", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/status/selected": { + "get": { + "summary": "Get selected multicast routes (`mrib_loc`, RPF-validated).", + "description": "When `group` is provided, returns a specific route. When `group` is omitted, returns all routes (with optional filters).", + "operationId": "get_mrib_selected", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family. Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "group", + "description": "Multicast group address. If provided, returns a specific route. If omitted, returns all routes matching the filters.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin (\"static\" or \"dynamic\"). Only used when listing all routes.", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to 77 for fleet-scoped multicast). Only used when `group` is set.", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/config/bestpath/fanout": { + "get": { + "operationId": "read_rib_bestpath_fanout", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutResponse" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_rib_bestpath_fanout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BestpathFanoutRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/imported": { + "get": { + "operationId": "get_rib_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/rib/status/selected": { + "get": { + "operationId": "get_rib_selected", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (None means all families)", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "protocol", + "description": "Filter by protocol (optional)", + "schema": { + "$ref": "#/components/schemas/ProtocolFilter" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Rib" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/mroute": { + "get": { + "summary": "List all static multicast routes.", + "operationId": "static_list_mcast_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "summary": "Add static multicast routes.", + "description": "This endpoint is intended for Nexus RPW use. Operators should configure multicast group membership through the Oxide API.", + "operationId": "static_add_mcast_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribAddStaticRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "summary": "Remove static multicast routes.", + "description": "This endpoint is intended for Nexus RPW use. Operators should configure multicast group membership through the Oxide API.", + "operationId": "static_remove_mcast_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MribDeleteStaticRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route4": { + "get": { + "operationId": "static_list_v4_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v4_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute4Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/static/route6": { + "get": { + "operationId": "static_list_v6_routes", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "title": "Map_of_Set_of_Path", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "static_add_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "static_remove_v6_route", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteStaticRoute6Request" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/switch/identifiers": { + "get": { + "operationId": "switch_identifiers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchIdentifiers" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AddPathElement": { + "description": "The add path element comes as a BGP capability extension as described in RFC 7911.", + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier. ", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier. There are a large pile of these ", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "send_receive": { + "description": "This field indicates whether the sender is (a) able to receive multiple paths from its peer (value 1), (b) able to send multiple paths to its peer (value 2), or (c) both (value 3) for the .", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi", + "send_receive" + ] + }, + "AddStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "AddStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "ApplyRequest": { + "description": "Apply changes to an ASN.", + "type": "object", + "properties": { + "asn": { + "description": "ASN to apply changes to.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "checker": { + "nullable": true, + "description": "Checker rhai code to apply to ingress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/CheckerSource" + } + ] + }, + "originate": { + "description": "Complete set of prefixes to originate. Any active prefixes not in this list will be removed. All prefixes in this list are ensured to be in the originating set.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "peers": { + "description": "Lists of peers indexed by peer group. Set's within a peer group key are a total set. For example, the value\n\n```text {\"foo\": [a, b, d]} ``` Means that the peer group \"foo\" only contains the peers `a`, `b` and `d`. If there is a peer `c` currently in the peer group \"foo\", it will be removed.", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpPeerConfig" + } + } + }, + "shaper": { + "nullable": true, + "description": "Checker rhai code to apply to egress open and update messages.", + "allOf": [ + { + "$ref": "#/components/schemas/ShaperSource" + } + ] + } + }, + "required": [ + "asn", + "originate", + "peers" + ] + }, + "As4PathSegment": { + "type": "object", + "properties": { + "typ": { + "$ref": "#/components/schemas/AsPathType" + }, + "value": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + }, + "required": [ + "typ", + "value" + ] + }, + "AsPathType": { + "description": "Enumeration describes possible AS path types", + "oneOf": [ + { + "description": "The path is to be interpreted as a set", + "type": "string", + "enum": [ + "as_set" + ] + }, + { + "description": "The path is to be interpreted as a sequence", + "type": "string", + "enum": [ + "as_sequence" + ] + } + ] + }, + "AsnSelector": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to get imported prefixes from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + }, + "BestpathFanoutRequest": { + "type": "object", + "properties": { + "fanout": { + "description": "Maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BestpathFanoutResponse": { + "type": "object", + "properties": { + "fanout": { + "description": "Current maximum number of equal-cost paths for ECMP forwarding", + "type": "integer", + "format": "uint8", + "minimum": 1 + } + }, + "required": [ + "fanout" + ] + }, + "BfdPeerConfig": { + "type": "object", + "properties": { + "detection_threshold": { + "description": "Detection threshold for connectivity as a multipler to required_rx", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "listen": { + "description": "Address to listen on for control messages from the peer.", + "type": "string", + "format": "ip" + }, + "mode": { + "description": "Mode is single-hop (RFC 5881) or multi-hop (RFC 5883).", + "allOf": [ + { + "$ref": "#/components/schemas/SessionMode" + } + ] + }, + "peer": { + "description": "Address of the peer to add.", + "type": "string", + "format": "ip" + }, + "required_rx": { + "description": "Acceptable time between control messages in microseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "detection_threshold", + "listen", + "mode", + "peer", + "required_rx" + ] + }, + "BfdPeerInfo": { + "type": "object", + "properties": { + "config": { + "$ref": "#/components/schemas/BfdPeerConfig" + }, + "state": { + "$ref": "#/components/schemas/BfdPeerState" + } + }, + "required": [ + "config", + "state" + ] + }, + "BfdPeerState": { + "description": "The possible peer states. See the `State` trait implementations `Down`, `Init`, and `Up` for detailed semantics. Data representation is u8 as this enum is used as a part of the BFD wire protocol.", + "oneOf": [ + { + "description": "A stable down state. Non-responsive to incoming messages.", + "type": "string", + "enum": [ + "AdminDown" + ] + }, + { + "description": "The initial state.", + "type": "string", + "enum": [ + "Down" + ] + }, + { + "description": "The peer has detected a remote peer in the down state.", + "type": "string", + "enum": [ + "Init" + ] + }, + { + "description": "The peer has detected a remote peer in the up or init state while in the init state.", + "type": "string", + "enum": [ + "Up" + ] + } + ] + }, + "BgpPathProperties": { + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "id": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "med": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "origin_as": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "peer": { + "type": "string", + "format": "ip" + }, + "stale": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "as_path", + "id", + "origin_as", + "peer" + ] + }, + "BgpPeerConfig": { + "type": "object", + "properties": { + "allow_export": { + "$ref": "#/components/schemas/ImportExportPolicy" + }, + "allow_import": { + "$ref": "#/components/schemas/ImportExportPolicy" + }, + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "allow_export", + "allow_import", + "communities", + "connect_retry", + "delay_open", + "enforce_first_as", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "Capability": { + "description": "Optional capabilities supported by a BGP implementation.", + "oneOf": [ + { + "description": "Multiprotocol extensions as defined in RFC 2858", + "type": "object", + "properties": { + "multiprotocol_extensions": { + "type": "object", + "properties": { + "afi": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + } + }, + "required": [ + "multiprotocol_extensions" + ], + "additionalProperties": false + }, + { + "description": "Route refresh capability as defined in RFC 2918.", + "type": "object", + "properties": { + "route_refresh": { + "type": "object" + } + }, + "required": [ + "route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Outbound filtering capability as defined in RFC 5291. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Multiple routes to destination capability as defined in RFC 8277 (deprecated). Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_routes_to_destination": { + "type": "object" + } + }, + "required": [ + "multiple_routes_to_destination" + ], + "additionalProperties": false + }, + { + "description": "Multiple nexthop encoding capability as defined in RFC 8950. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "extended_next_hop_encoding": { + "type": "object" + } + }, + "required": [ + "extended_next_hop_encoding" + ], + "additionalProperties": false + }, + { + "description": "Extended message capability as defined in RFC 8654. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "b_g_p_extended_message": { + "type": "object" + } + }, + "required": [ + "b_g_p_extended_message" + ], + "additionalProperties": false + }, + { + "description": "BGPSec as defined in RFC 8205. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_sec": { + "type": "object" + } + }, + "required": [ + "bgp_sec" + ], + "additionalProperties": false + }, + { + "description": "Multiple label support as defined in RFC 8277. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "multiple_labels": { + "type": "object" + } + }, + "required": [ + "multiple_labels" + ], + "additionalProperties": false + }, + { + "description": "BGP role capability as defined in RFC 9234. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "bgp_role": { + "type": "object" + } + }, + "required": [ + "bgp_role" + ], + "additionalProperties": false + }, + { + "description": "Graceful restart as defined in RFC 4724. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "graceful_restart": { + "type": "object" + } + }, + "required": [ + "graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Four octet AS numbers as defined in RFC 6793.", + "type": "object", + "properties": { + "four_octet_as": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "asn" + ] + } + }, + "required": [ + "four_octet_as" + ], + "additionalProperties": false + }, + { + "description": "Dynamic capabilities as defined in draft-ietf-idr-dynamic-cap. Note this capability is not yet implemented.", + "type": "object", + "properties": { + "dynamic_capability": { + "type": "object" + } + }, + "required": [ + "dynamic_capability" + ], + "additionalProperties": false + }, + { + "description": "Multi session support as defined in draft-ietf-idr-bgp-multisession. Note this capability is not yet supported.", + "type": "object", + "properties": { + "multisession_bgp": { + "type": "object" + } + }, + "required": [ + "multisession_bgp" + ], + "additionalProperties": false + }, + { + "description": "Add path capability as defined in RFC 7911.", + "type": "object", + "properties": { + "add_path": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AddPathElement" + }, + "uniqueItems": true + } + }, + "required": [ + "elements" + ] + } + }, + "required": [ + "add_path" + ], + "additionalProperties": false + }, + { + "description": "Enhanced route refresh as defined in RFC 7313. Note this capability is not yet supported.", + "type": "object", + "properties": { + "enhanced_route_refresh": { + "type": "object" + } + }, + "required": [ + "enhanced_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Long-lived graceful restart as defined in draft-uttaro-idr-bgp-persistence. Note this capability is not yet supported.", + "type": "object", + "properties": { + "long_lived_graceful_restart": { + "type": "object" + } + }, + "required": [ + "long_lived_graceful_restart" + ], + "additionalProperties": false + }, + { + "description": "Routing policy distribution as defined indraft-ietf-idr-rpd-04. Note this capability is not yet supported.", + "type": "object", + "properties": { + "routing_policy_distribution": { + "type": "object" + } + }, + "required": [ + "routing_policy_distribution" + ], + "additionalProperties": false + }, + { + "description": "Fully qualified domain names as defined intdraft-walton-bgp-hostname-capability. Note this capability is not yet supported.", + "type": "object", + "properties": { + "fqdn": { + "type": "object" + } + }, + "required": [ + "fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard route refresh as defined in RFC 8810 (deprecated). Note this capability is not yet supported.", + "type": "object", + "properties": { + "prestandard_route_refresh": { + "type": "object" + } + }, + "required": [ + "prestandard_route_refresh" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard prefix-based outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_orf_and_pd": { + "type": "object" + } + }, + "required": [ + "prestandard_orf_and_pd" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard outbound route filtering as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_outbound_route_filtering": { + "type": "object" + } + }, + "required": [ + "prestandard_outbound_route_filtering" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard multisession as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_multisession": { + "type": "object" + } + }, + "required": [ + "prestandard_multisession" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard fully qualified domain names as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_fqdn": { + "type": "object" + } + }, + "required": [ + "prestandard_fqdn" + ], + "additionalProperties": false + }, + { + "description": "Pre-standard operational messages as defined in RFC 8810 (deprecated). Note this is not yet implemented.", + "type": "object", + "properties": { + "prestandard_operational_message": { + "type": "object" + } + }, + "required": [ + "prestandard_operational_message" + ], + "additionalProperties": false + }, + { + "description": "Experimental capability as defined in RFC 8810.", + "type": "object", + "properties": { + "experimental": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "experimental" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "unassigned": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "unassigned" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reserved": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "code" + ] + } + }, + "required": [ + "reserved" + ], + "additionalProperties": false + } + ] + }, + "CeaseErrorSubcode": { + "description": "Cease error subcode types from RFC 4486", + "type": "string", + "enum": [ + "unspecific", + "maximum_numberof_prefixes_reached", + "administrative_shutdown", + "peer_deconfigured", + "administrative_reset", + "connection_rejected", + "other_configuration_change", + "connection_collision_resolution", + "out_of_resources" + ] + }, + "CheckerSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "Community": { + "description": "BGP community value", + "oneOf": [ + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised outside a BGP confederation boundary (a stand-alone autonomous system that is not part of a confederation should be considered a confederation itself)", + "type": "string", + "enum": [ + "no_export" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to other BGP peers.", + "type": "string", + "enum": [ + "no_advertise" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value MUST NOT be advertised to external BGP peers (this includes peers in other members autonomous systems inside a BGP confederation).", + "type": "string", + "enum": [ + "no_export_sub_confed" + ] + }, + { + "description": "All routes received carrying a communities attribute containing this value must set the local preference for the received routes to a low value, preferably zero.", + "type": "string", + "enum": [ + "graceful_shutdown" + ] + }, + { + "description": "A user defined community", + "type": "object", + "properties": { + "user_defined": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "user_defined" + ], + "additionalProperties": false + } + ] + }, + "ConnectionId": { + "description": "Unique identifier for a BGP connection instance", + "type": "object", + "properties": { + "local": { + "description": "Local socket address for this connection", + "type": "string" + }, + "remote": { + "description": "Remote socket address for this connection", + "type": "string" + }, + "uuid": { + "description": "Unique identifier for this connection instance", + "type": "string", + "format": "uuid" + } + }, + "required": [ + "local", + "remote", + "uuid" + ] + }, + "DeleteStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "DeleteStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Duration": { + "type": "object", + "properties": { + "nanos": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "secs": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "nanos", + "secs" + ] + }, + "DynamicTimerInfo": { + "type": "object", + "properties": { + "configured": { + "$ref": "#/components/schemas/Duration" + }, + "negotiated": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "negotiated" + ] + }, + "Error": { + "description": "Error information from a response.", + "type": "object", + "properties": { + "error_code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "message", + "request_id" + ] + }, + "ErrorCode": { + "description": "This enumeration contains possible notification error codes.", + "type": "string", + "enum": [ + "header", + "open", + "update", + "hold_timer_expired", + "fsm", + "cease" + ] + }, + "ErrorSubcode": { + "description": "This enumeration contains possible notification error subcodes.", + "oneOf": [ + { + "type": "object", + "properties": { + "header": { + "$ref": "#/components/schemas/HeaderErrorSubcode" + } + }, + "required": [ + "header" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "open": { + "$ref": "#/components/schemas/OpenErrorSubcode" + } + }, + "required": [ + "open" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "update": { + "$ref": "#/components/schemas/UpdateErrorSubcode" + } + }, + "required": [ + "update" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "hold_time": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "hold_time" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "fsm": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "fsm" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "cease": { + "$ref": "#/components/schemas/CeaseErrorSubcode" + } + }, + "required": [ + "cease" + ], + "additionalProperties": false + } + ] + }, + "FsmEventBuffer": { + "oneOf": [ + { + "description": "All FSM events (high frequency, includes all timers)", + "type": "string", + "enum": [ + "all" + ] + }, + { + "description": "Major events only (state transitions, admin, new connections)", + "type": "string", + "enum": [ + "major" + ] + } + ] + }, + "FsmEventCategory": { + "description": "Category of FSM event for filtering and display purposes", + "type": "string", + "enum": [ + "Admin", + "Connection", + "Session", + "StateTransition" + ] + }, + "FsmEventRecord": { + "description": "Serializable record of an FSM event with full context", + "type": "object", + "properties": { + "connection_id": { + "nullable": true, + "description": "Connection ID if event is connection-specific", + "allOf": [ + { + "$ref": "#/components/schemas/ConnectionId" + } + ] + }, + "current_state": { + "description": "FSM state at time of event", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "details": { + "nullable": true, + "description": "Additional event details (e.g., \"Received OPEN\", \"Admin command\")", + "type": "string" + }, + "event_category": { + "description": "High-level event category", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventCategory" + } + ] + }, + "event_type": { + "description": "Specific event type as string (e.g., \"ManualStart\", \"HoldTimerExpires\")", + "type": "string" + }, + "previous_state": { + "nullable": true, + "description": "Previous state if this caused a transition", + "allOf": [ + { + "$ref": "#/components/schemas/FsmStateKind" + } + ] + }, + "timestamp": { + "description": "UTC timestamp when event occurred", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "current_state", + "event_category", + "event_type", + "timestamp" + ] + }, + "FsmHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "buffer": { + "nullable": true, + "description": "Which buffer to retrieve - if None, returns major buffer", + "allOf": [ + { + "$ref": "#/components/schemas/FsmEventBuffer" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "FsmHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "description": "Events organized by peer address Each peer's value contains only the events from the requested buffer", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsmEventRecord" + } + } + } + }, + "required": [ + "by_peer" + ] + }, + "FsmStateKind": { + "description": "Simplified representation of a BGP state without having to carry a connection.", + "oneOf": [ + { + "description": "Initial state. Refuse all incomming BGP connections. No resources allocated to peer.", + "type": "string", + "enum": [ + "Idle" + ] + }, + { + "description": "Waiting for the TCP connection to be completed.", + "type": "string", + "enum": [ + "Connect" + ] + }, + { + "description": "Trying to acquire peer by listening for and accepting a TCP connection.", + "type": "string", + "enum": [ + "Active" + ] + }, + { + "description": "Waiting for open message from peer.", + "type": "string", + "enum": [ + "OpenSent" + ] + }, + { + "description": "Waiting for keepaliave or notification from peer.", + "type": "string", + "enum": [ + "OpenConfirm" + ] + }, + { + "description": "Handler for Connection Collisions (RFC 4271 6.8)", + "type": "string", + "enum": [ + "ConnectionCollision" + ] + }, + { + "description": "Sync up with peers.", + "type": "string", + "enum": [ + "SessionSetup" + ] + }, + { + "description": "Able to exchange update, notification and keepliave messages with peers.", + "type": "string", + "enum": [ + "Established" + ] + } + ] + }, + "HeaderErrorSubcode": { + "description": "Header error subcode types", + "type": "string", + "enum": [ + "unspecific", + "connection_not_synchronized", + "bad_message_length", + "bad_message_type" + ] + }, + "ImportExportPolicy": { + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "Message": { + "description": "Holds a BGP message. May be an Open, Update, Notification or Keep Alive message.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "open" + ] + }, + "value": { + "$ref": "#/components/schemas/OpenMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "update" + ] + }, + "value": { + "$ref": "#/components/schemas/UpdateMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "notification" + ] + }, + "value": { + "$ref": "#/components/schemas/NotificationMessage" + } + }, + "required": [ + "type", + "value" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "keep_alive" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "route_refresh" + ] + }, + "value": { + "$ref": "#/components/schemas/RouteRefreshMessage" + } + }, + "required": [ + "type", + "value" + ] + } + ] + }, + "MessageDirection": { + "type": "string", + "enum": [ + "sent", + "received" + ] + }, + "MessageHistory": { + "description": "Message history for a BGP session", + "type": "object", + "properties": { + "received": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + }, + "sent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageHistoryEntry" + } + } + }, + "required": [ + "received", + "sent" + ] + }, + "MessageHistoryEntry": { + "description": "A message history entry is a BGP message with an associated timestamp and connection ID", + "type": "object", + "properties": { + "connection_id": { + "$ref": "#/components/schemas/ConnectionId" + }, + "message": { + "$ref": "#/components/schemas/Message" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "connection_id", + "message", + "timestamp" + ] + }, + "MessageHistoryRequest": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the BGP router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "direction": { + "nullable": true, + "description": "Optional direction filter - if None, returns both sent and received", + "allOf": [ + { + "$ref": "#/components/schemas/MessageDirection" + } + ] + }, + "peer": { + "nullable": true, + "description": "Optional peer filter - if None, returns history for all peers", + "type": "string", + "format": "ip" + } + }, + "required": [ + "asn" + ] + }, + "MessageHistoryResponse": { + "type": "object", + "properties": { + "by_peer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MessageHistory" + } + } + }, + "required": [ + "by_peer" + ] + }, + "MribAddStaticRequest": { + "description": "Request body for adding static multicast routes to the MRIB.", + "type": "object", + "properties": { + "routes": { + "description": "List of static multicast routes to add.", + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticMulticastRouteInput" + } + } + }, + "required": [ + "routes" + ] + }, + "MribDeleteStaticRequest": { + "description": "Request body for deleting static multicast routes from the MRIB.", + "type": "object", + "properties": { + "keys": { + "description": "List of route keys to delete.", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRouteKey" + } + } + }, + "required": [ + "keys" + ] + }, + "MribRpfRebuildIntervalRequest": { + "description": "Request body for setting the RPF rebuild interval.", + "type": "object", + "properties": { + "interval_ms": { + "description": "Minimum interval between RPF cache rebuilds in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "interval_ms" + ] + }, + "MribRpfRebuildIntervalResponse": { + "description": "Response containing the current RPF rebuild interval.", + "type": "object", + "properties": { + "interval_ms": { + "description": "Minimum interval between RPF cache rebuilds in milliseconds.", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "interval_ms" + ] + }, + "MulticastRoute": { + "description": "Multicast route entry containing replication groups and metadata.", + "type": "object", + "properties": { + "created": { + "description": "Creation timestamp.", + "type": "string", + "format": "date-time" + }, + "key": { + "description": "The multicast route key (S,G) or (*,G).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteKey" + } + ] + }, + "rpf_neighbor": { + "nullable": true, + "description": "Expected RPF neighbor for the source (for RPF checks).", + "type": "string", + "format": "ip" + }, + "source": { + "description": "Route source (static, IGMP, etc.).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteSource" + } + ] + }, + "underlay_group": { + "description": "Underlay multicast group address (ff04::X).\n\nAdmin-local scoped IPv6 multicast address corresponding to the overlay multicast group. 1:1 mapped and always derived from the overlay multicast group in Omicron.", + "type": "string", + "format": "ipv6" + }, + "underlay_nexthops": { + "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", + "type": "array", + "items": { + "type": "string", + "format": "ipv6" + }, + "uniqueItems": true + }, + "updated": { + "description": "Last updated timestamp.\n\nOnly updated when route fields change semantically (rpf_neighbor, underlay_group, underlay_nexthops, source). An idempotent upsert with an identical value does not update this timestamp.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "created", + "key", + "source", + "underlay_group", + "underlay_nexthops", + "updated" + ] + }, + "MulticastRouteKey": { + "description": "Multicast route key: (Source, Group) pair for source-specific multicast, or (*, Group) for any-source multicast.\n\nUses type-enforced address family matching: IPv4 sources can only be paired with IPv4 groups, and IPv6 sources with IPv6 groups.", + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "$ref": "#/components/schemas/MulticastRouteKeyV4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "$ref": "#/components/schemas/MulticastRouteKeyV6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "MulticastRouteKeyV4": { + "description": "IPv4 multicast route key with type-enforced address family matching.", + "type": "object", + "properties": { + "group": { + "description": "Multicast group address.", + "type": "string", + "format": "ipv4" + }, + "source": { + "nullable": true, + "description": "Source address (`None` for (*,G) routes).", + "type": "string", + "format": "ipv4" + }, + "vni": { + "description": "VNI (Virtual Network Identifier).", + "default": 77, + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "group" + ] + }, + "MulticastRouteKeyV6": { + "description": "IPv6 multicast route key with type-enforced address family matching.", + "type": "object", + "properties": { + "group": { + "description": "Multicast group address.", + "type": "string", + "format": "ipv6" + }, + "source": { + "nullable": true, + "description": "Source address (`None` for (*,G) routes).", + "type": "string", + "format": "ipv6" + }, + "vni": { + "description": "VNI (Virtual Network Identifier).", + "default": 77, + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "group" + ] + }, + "MulticastRouteSource": { + "description": "Source of a multicast route entry.", + "oneOf": [ + { + "description": "Static route configured via API.", + "type": "string", + "enum": [ + "Static" + ] + }, + { + "description": "Learned via IGMP snooping (future).", + "type": "string", + "enum": [ + "Igmp" + ] + }, + { + "description": "Learned via MLD snooping (future).", + "type": "string", + "enum": [ + "Mld" + ] + } + ] + }, + "Neighbor": { + "type": "object", + "properties": { + "allow_export": { + "$ref": "#/components/schemas/ImportExportPolicy" + }, + "allow_import": { + "$ref": "#/components/schemas/ImportExportPolicy" + }, + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "enforce_first_as": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "local_pref": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "md5_auth_key": { + "nullable": true, + "type": "string" + }, + "min_ttl": { + "nullable": true, + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "multi_exit_discriminator": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "passive": { + "type": "boolean" + }, + "remote_asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "resolution": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "allow_export", + "allow_import", + "asn", + "communities", + "connect_retry", + "delay_open", + "enforce_first_as", + "group", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "NeighborResetOp": { + "type": "string", + "enum": [ + "Hard", + "SoftInbound", + "SoftOutbound" + ] + }, + "NeighborResetRequest": { + "type": "object", + "properties": { + "addr": { + "type": "string", + "format": "ip" + }, + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "op": { + "$ref": "#/components/schemas/NeighborResetOp" + } + }, + "required": [ + "addr", + "asn", + "op" + ] + }, + "NotificationMessage": { + "description": "Notification messages are exchanged between BGP peers when an exceptional event has occurred.", + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "error_code": { + "description": "Error code associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorCode" + } + ] + }, + "error_subcode": { + "description": "Error subcode associated with the notification", + "allOf": [ + { + "$ref": "#/components/schemas/ErrorSubcode" + } + ] + } + }, + "required": [ + "data", + "error_code", + "error_subcode" + ] + }, + "OpenErrorSubcode": { + "description": "Open message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "unsupported_version_number", + "bad_peer_a_s", + "bad_bgp_identifier", + "unsupported_optional_parameter", + "deprecated", + "unacceptable_hold_time", + "unsupported_capability" + ] + }, + "OpenMessage": { + "description": "The first message sent by each side once a TCP connection is established.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Version | My Autonomous System | Hold Time : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | BGP Identifier : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : | Opt Parm Len | Optional Parameters : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Optional Parameters (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.2", + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number of the sender. When 4-byte ASNs are in use this value is set to AS_TRANS which has a value of 23456.\n\nRef: RFC 4893 §7", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "hold_time": { + "description": "Number of seconds the sender proposes for the hold timer.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "id": { + "description": "BGP identifier of the sender", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "parameters": { + "description": "A list of optional parameters.", + "type": "array", + "items": { + "$ref": "#/components/schemas/OptionalParameter" + } + }, + "version": { + "description": "BGP protocol version.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "asn", + "hold_time", + "id", + "parameters", + "version" + ] + }, + "OptionalParameter": { + "description": "The IANA/IETF currently defines the following optional parameter types.", + "oneOf": [ + { + "description": "Code 0", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "reserved" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 1: RFC 4217, RFC 5492 (deprecated)", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "authentication" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 2: RFC 5492", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "capabilities" + ] + }, + "value": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Capability" + }, + "uniqueItems": true + } + }, + "required": [ + "type", + "value" + ] + }, + { + "description": "Unassigned", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "unassigned" + ] + } + }, + "required": [ + "type" + ] + }, + { + "description": "Code 255: RFC 9072", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "extended_length" + ] + } + }, + "required": [ + "type" + ] + } + ] + }, + "Origin4": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Origin6": { + "type": "object", + "properties": { + "asn": { + "description": "ASN of the router to originate from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "prefixes": { + "description": "Set of prefixes to originate.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "asn", + "prefixes" + ] + }, + "Path": { + "type": "object", + "properties": { + "bgp": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/BgpPathProperties" + } + ] + }, + "nexthop": { + "type": "string", + "format": "ip" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "shutdown": { + "type": "boolean" + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "rib_priority", + "shutdown" + ] + }, + "PathAttribute": { + "description": "A self-describing BGP path attribute", + "type": "object", + "properties": { + "typ": { + "description": "Type encoding for the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeType" + } + ] + }, + "value": { + "description": "Value of the attribute", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeValue" + } + ] + } + }, + "required": [ + "typ", + "value" + ] + }, + "PathAttributeType": { + "description": "Type encoding for a path attribute.", + "type": "object", + "properties": { + "flags": { + "description": "Flags may include, Optional, Transitive, Partial and Extended Length.", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "type_code": { + "description": "Type code for the path attribute.", + "allOf": [ + { + "$ref": "#/components/schemas/PathAttributeTypeCode" + } + ] + } + }, + "required": [ + "flags", + "type_code" + ] + }, + "PathAttributeTypeCode": { + "description": "An enumeration describing available path attribute type codes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "as_path", + "next_hop", + "multi_exit_disc", + "local_pref", + "atomic_aggregate", + "aggregator", + "communities", + "as4_aggregator" + ] + }, + { + "description": "RFC 4271", + "type": "string", + "enum": [ + "origin" + ] + }, + { + "description": "RFC 6793", + "type": "string", + "enum": [ + "as4_path" + ] + } + ] + }, + "PathAttributeValue": { + "description": "The value encoding of a path attribute.", + "oneOf": [ + { + "description": "The type of origin associated with a path", + "type": "object", + "properties": { + "origin": { + "$ref": "#/components/schemas/PathOrigin" + } + }, + "required": [ + "origin" + ], + "additionalProperties": false + }, + { + "description": "The AS set associated with a path", + "type": "object", + "properties": { + "as_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as_path" + ], + "additionalProperties": false + }, + { + "description": "The nexthop associated with a path", + "type": "object", + "properties": { + "next_hop": { + "type": "string", + "format": "ip" + } + }, + "required": [ + "next_hop" + ], + "additionalProperties": false + }, + { + "description": "A metric used for external (inter-AS) links to discriminate among multiple entry or exit points.", + "type": "object", + "properties": { + "multi_exit_disc": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "multi_exit_disc" + ], + "additionalProperties": false + }, + { + "description": "Local pref is included in update messages sent to internal peers and indicates a degree of preference.", + "type": "object", + "properties": { + "local_pref": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "local_pref" + ], + "additionalProperties": false + }, + { + "description": "This attribute is included in routes that are formed by aggregation.", + "type": "object", + "properties": { + "aggregator": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "minItems": 6, + "maxItems": 6 + } + }, + "required": [ + "aggregator" + ], + "additionalProperties": false + }, + { + "description": "Indicates communities associated with a path.", + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Community" + } + } + }, + "required": [ + "communities" + ], + "additionalProperties": false + }, + { + "description": "The 4-byte encoded AS set associated with a path", + "type": "object", + "properties": { + "as4_path": { + "type": "array", + "items": { + "$ref": "#/components/schemas/As4PathSegment" + } + } + }, + "required": [ + "as4_path" + ], + "additionalProperties": false + }, + { + "description": "This attribute is included in routes that are formed by aggregation.", + "type": "object", + "properties": { + "as4_aggregator": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "minItems": 8, + "maxItems": 8 + } + }, + "required": [ + "as4_aggregator" + ], + "additionalProperties": false + } + ] + }, + "PathOrigin": { + "description": "An enumeration indicating the origin type of a path.", + "oneOf": [ + { + "description": "Interior gateway protocol", + "type": "string", + "enum": [ + "igp" + ] + }, + { + "description": "Exterior gateway protocol", + "type": "string", + "enum": [ + "egp" + ] + }, + { + "description": "Incomplete path origin", + "type": "string", + "enum": [ + "incomplete" + ] + } + ] + }, + "PeerInfo": { + "type": "object", + "properties": { + "asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "duration_millis": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "timers": { + "$ref": "#/components/schemas/PeerTimers" + } + }, + "required": [ + "duration_millis", + "state", + "timers" + ] + }, + "PeerTimers": { + "type": "object", + "properties": { + "hold": { + "$ref": "#/components/schemas/DynamicTimerInfo" + }, + "keepalive": { + "$ref": "#/components/schemas/DynamicTimerInfo" + } + }, + "required": [ + "hold", + "keepalive" + ] + }, + "Prefix": { + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "Prefix4": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "length", + "value" + ] + }, + "Prefix6": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "value": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "length", + "value" + ] + }, + "Rib": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Path" + }, + "uniqueItems": true + } + }, + "RouteRefreshMessage": { + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier.", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + }, + "Router": { + "type": "object", + "properties": { + "asn": { + "description": "Autonomous system number for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "graceful_shutdown": { + "description": "Gracefully shut this router down.", + "type": "boolean" + }, + "id": { + "description": "Id for this router", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "listen": { + "description": "Listening address :", + "type": "string" + } + }, + "required": [ + "asn", + "graceful_shutdown", + "id", + "listen" + ] + }, + "SessionMode": { + "type": "string", + "enum": [ + "SingleHop", + "MultiHop" + ] + }, + "ShaperSource": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "code": { + "type": "string" + } + }, + "required": [ + "asn", + "code" + ] + }, + "StaticMulticastRouteInput": { + "description": "Input for adding static multicast routes.", + "type": "object", + "properties": { + "key": { + "description": "The multicast route key (S,G) or (*,G).", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastRouteKey" + } + ] + }, + "underlay_group": { + "description": "Underlay multicast group address (ff04::X).\n\nAdmin-local scoped IPv6 multicast address corresponding to the overlay multicast group. 1:1 mapped and always derived from the overlay multicast group in Omicron.", + "type": "string", + "format": "ipv6" + }, + "underlay_nexthops": { + "description": "Underlay unicast nexthops for multicast replication.\n\nUnicast IPv6 addresses where encapsulated overlay multicast traffic is forwarded. These are sled underlay addresses hosting VMs subscribed to the multicast group. Forms the outgoing interface list (OIL).", + "type": "array", + "items": { + "type": "string", + "format": "ipv6" + } + } + }, + "required": [ + "key", + "underlay_group", + "underlay_nexthops" + ] + }, + "StaticRoute4": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv4" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix4" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute4List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute4" + } + } + }, + "required": [ + "list" + ] + }, + "StaticRoute6": { + "type": "object", + "properties": { + "nexthop": { + "type": "string", + "format": "ipv6" + }, + "prefix": { + "$ref": "#/components/schemas/Prefix6" + }, + "rib_priority": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "nexthop", + "prefix", + "rib_priority" + ] + }, + "StaticRoute6List": { + "type": "object", + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StaticRoute6" + } + } + }, + "required": [ + "list" + ] + }, + "SwitchIdentifiers": { + "description": "Identifiers for a switch.", + "type": "object", + "properties": { + "slot": { + "nullable": true, + "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + } + }, + "UpdateErrorSubcode": { + "description": "Update message error subcode types", + "type": "string", + "enum": [ + "unspecific", + "malformed_attribute_list", + "unrecognized_well_known_attribute", + "missing_well_known_attribute", + "attribute_flags", + "attribute_length", + "invalid_origin_attribute", + "deprecated", + "invalid_nexthop_attribute", + "optional_attribute", + "invalid_network_field", + "malformed_as_path" + ] + }, + "UpdateMessage": { + "description": "An update message is used to advertise feasible routes that share common path attributes to a peer, or to withdraw multiple unfeasible routes from service.\n\n```text 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Witdrawn Length | Withdrawn Routes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Withdrawn Routes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Path Attribute Length | Path Attributes : +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Path Attributes (cont, variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ : : : Network Layer Reachability Information (variable) : : | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ ```\n\nRef: RFC 4271 §4.3", + "type": "object", + "properties": { + "nlri": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + } + }, + "path_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathAttribute" + } + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix" + } + } + }, + "required": [ + "nlri", + "path_attributes", + "withdrawn" + ] + }, + "AddressFamily": { + "description": "Represents the address family (protocol version) for network routes.\n\nThis is the canonical source of truth for address family definitions across the entire codebase. All routing-related components (RIB operations, BGP messages, API filtering, CLI tools) use this single enum rather than defining their own.\n\n# Semantics\n\nWhen used in filtering contexts (e.g., database queries or API parameters), `Option` is preferred: - `None` = no filter (match all address families) - `Some(Ipv4)` = IPv4 routes only - `Some(Ipv6)` = IPv6 routes only\n\n# Examples\n\n``` use rdb_types::AddressFamily;\n\nlet ipv4 = AddressFamily::Ipv4; let ipv6 = AddressFamily::Ipv6;\n\n// For filtering, use Option let filter: Option = Some(AddressFamily::Ipv4); let no_filter: Option = None; // matches all families ```", + "oneOf": [ + { + "description": "Internet Protocol Version 4 (IPv4)", + "type": "string", + "enum": [ + "Ipv4" + ] + }, + { + "description": "Internet Protocol Version 6 (IPv6)", + "type": "string", + "enum": [ + "Ipv6" + ] + } + ] + }, + "RouteOriginFilter": { + "description": "Filter for multicast route origin.", + "oneOf": [ + { + "description": "Static routes only (operator configured).", + "type": "string", + "enum": [ + "static" + ] + }, + { + "description": "Dynamic routes only (learned via IGMP, MLD, etc.).", + "type": "string", + "enum": [ + "dynamic" + ] + } + ] + }, + "ProtocolFilter": { + "oneOf": [ + { + "description": "BGP routes only", + "type": "string", + "enum": [ + "Bgp" + ] + }, + { + "description": "Static routes only", + "type": "string", + "enum": [ + "Static" + ] + } + ] + } + }, + "responses": { + "Error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 64bfad20..00c019ce 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-3.0.0-08e2f2.json \ No newline at end of file +mg-admin-4.0.0-8f51c3.json \ No newline at end of file diff --git a/rdb/Cargo.toml b/rdb/Cargo.toml index b4c228e2..caef43c0 100644 --- a/rdb/Cargo.toml +++ b/rdb/Cargo.toml @@ -18,6 +18,8 @@ chrono.workspace = true clap = { workspace = true, optional = true } oxnet.workspace = true rdb-types = { workspace = true, features = ["clap"] } +poptrie.workspace = true +omicron-common.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 52c7c119..7dad87dd 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -9,19 +9,37 @@ //! in a sled key-value store that is persisted to disk via flush operations. //! Volatile information is stored in in-memory data structures such as hash //! sets. +//! +//! ## Lock Ordering +//! +//! The Db struct contains multiple locks, which we acquire +//! in this ordering: +//! +//! 1. Unicast RIB locks (`rib4_in`, `rib4_loc`, `rib6_in`, `rib6_loc`) +//! 2. MRIB locks (see [`mrib`] module: `mrib_in` → `mrib_loc` → `watchers`) +//! 3. RpfTable poptrie caches (`cache_v4`, `cache_v6`) +//! +//! **RPF lookup exception**: RPF lookups ([`mrib::rpf::RpfTable::lookup`]) hold +//! at most one lock at a time. They first try the poptrie cache (read lock), +//! release it, then fall back to linear scan (RIB lock) if needed. This avoids +//! deadlocks since no path holds cache + RIB locks simultaneously. + use crate::bestpath::bestpaths; use crate::error::Error; use crate::log::rdb_log; +use crate::mrib; +use crate::mrib::rpf::RpfTable; +use crate::mrib::{Mrib, spawn_rpf_revalidator}; use crate::types::*; use chrono::Utc; use mg_common::{lock, read_lock, write_lock}; use sled::Tree; -use slog::{Logger, error}; +use slog::{Logger, debug, error}; use std::cmp::Ordering as CmpOrdering; use std::collections::{BTreeMap, BTreeSet}; use std::net::{IpAddr, Ipv6Addr}; use std::num::NonZeroU8; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, Mutex, RwLock}; use std::thread::{sleep, spawn}; @@ -55,6 +73,10 @@ const STATIC4_ROUTES: &str = "static4_routes"; /// The handle used to open a persistent key-value tree for IPv6 static routes. const STATIC6_ROUTES: &str = "static6_routes"; +/// The handle used to open a persistent key-value tree for multicast static +/// routes. +const STATIC_MCAST_ROUTES: &str = "static_mcast_routes"; + /// Key used in settings tree for tunnel endpoint setting const TEP_KEY: &str = "tep"; @@ -68,10 +90,36 @@ const BESTPATH_FANOUT: &str = "bestpath_fanout"; /// Default bestpath fanout value. Maximum number of ECMP paths in RIB. const DEFAULT_BESTPATH_FANOUT: u8 = 1; +/// Key used in settings tree for MRIB RPF rebuild interval. +const MRIB_RPF_REBUILD_INTERVAL: &str = "mrib_rpf_rebuild_interval"; + +/// Default MRIB RPF rebuild interval in milliseconds. +/// +/// Multicast route additions can be bursty, and poptrie rebuilds can be +/// expensive. During rebuilds, RPF verification falls back to linear scan. +const DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS: u64 = 1000; + pub type Rib = BTreeMap>; pub type Rib4 = BTreeMap>; pub type Rib6 = BTreeMap>; +/// Cached configuration values for low-overhead reads. +/// +/// These atomic values are read frequently by hot paths (bestpath selection, +/// RPF revalidation) and cached here to avoid locking the persistent store. +#[derive(Debug, Clone)] +struct CachedConfig { + /// Bestpath fanout for ECMP. + /// + /// This controls how many equal-cost paths are selected for unicast routing + /// and considered for multicast RPF lookup. + bestpath_fanout: Arc, + + /// Periodic RPF revalidation sweep interval in milliseconds. + /// 0 means use [`mrib::DEFAULT_REVALIDATION_INTERVAL`]. + rpf_revalidation_interval_ms: Arc, +} + /// The central routing information base. Both persistent an volatile route /// information is managed through this structure. #[derive(Clone)] @@ -95,6 +143,17 @@ pub struct Db { /// added to the lower half forwarding plane. rib6_loc: Arc>, + /// Multicast routing information base (MRIB). + mrib: Mrib, + + /// [RPF] (Reverse Path Forwarding) table for multicast route verification. + /// + /// [RPF]: https://datatracker.ietf.org/doc/html/rfc5110 + rpf_table: RpfTable, + + /// Cached configuration for low-overhead reads on (possibly) hot paths. + config: CachedConfig, + /// A generation number for the overall data store. generation: Arc, @@ -110,6 +169,7 @@ pub struct Db { log: Logger, } + unsafe impl Sync for Db {} unsafe impl Send for Db {} @@ -119,23 +179,69 @@ struct Watcher { sender: Sender, } -//TODO we need bulk operations with atomic semantics here. +// TODO we need bulk operations with atomic semantics here. impl Db { /// Create a new routing database that stores persistent data at `path`. pub fn new(path: &str, log: Logger) -> Result { let rib_loc = Arc::new(Mutex::new(Rib::new())); - Ok(Self { - persistent: sled::open(path)?, + let persistent = sled::open(path)?; + + // Load bestpath fanout from settings, defaulting if not set. + let config = CachedConfig { + bestpath_fanout: Arc::new(AtomicU8::new( + persistent + .open_tree(SETTINGS) + .and_then(|tree| tree.get(BESTPATH_FANOUT)) + .ok() + .flatten() + .and_then(|v| v.first().copied()) + .unwrap_or(DEFAULT_BESTPATH_FANOUT) + .max(1), + )), + rpf_revalidation_interval_ms: Arc::new(AtomicU64::new( + mrib::DEFAULT_REVALIDATION_INTERVAL.as_millis() as u64, + )), + }; + + let db = Self { + persistent, rib4_in: Arc::new(Mutex::new(BTreeMap::new())), rib4_loc: Arc::new(Mutex::new(BTreeMap::new())), rib6_in: Arc::new(Mutex::new(BTreeMap::new())), rib6_loc: Arc::new(Mutex::new(BTreeMap::new())), + mrib: Mrib::new(log.clone()), + rpf_table: RpfTable::new(log.clone()), + config, generation: Arc::new(AtomicU64::new(0)), watchers: Arc::new(RwLock::new(Vec::new())), reaper: Reaper::new(rib_loc), slot: Arc::new(RwLock::new(None)), log, - }) + }; + + // Load persisted static multicast routes into `mrib_in` + db.load_mcast_static_routes(); + + // Load RPF rebuild interval from settings and apply to `RpfTable` + let rebuild_interval = db.get_mrib_rpf_rebuild_interval().unwrap_or_else(|e| { + error!( + db.log, + "failed to load mrib_rpf_rebuild_interval from settings: {e}" + ); + std::time::Duration::from_millis( + DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, + ) + }); + + db.rpf_table.set_rebuild_interval(rebuild_interval); + + // Start RPF revalidator to handle unicast route changes. + // When the poptrie cache rebuilds, revalidate all (S,G) multicast routes. + if let Some(tx) = spawn_rpf_revalidator(db.clone()) { + db.rpf_table.set_rebuild_notifier(tx); + } + + Ok(db) } pub fn set_reaper_interval(&self, interval: std::time::Duration) { @@ -146,6 +252,68 @@ impl Db { *lock!(self.reaper.stale_max) = stale_max; } + pub fn slot(&self) -> Option { + match self.slot.read() { + Ok(v) => *v, + Err(e) => { + error!(self.log, "unable to read switch slot"; "error" => %e); + None + } + } + } + + pub fn set_slot(&mut self, slot: Option) { + let mut value = self.slot.write().unwrap(); + *value = slot; + } + + // ------------------------------------------------------------------------ + // MRIB / RPF revalidator gettings/setters + // ------------------------------------------------------------------------ + + /// Set the interval for periodic RPF revalidation sweeps. + /// + /// This controls how often the revalidator thread walks the MRIB to + /// re-check (S,G) routes even without explicit unicast RIB changes. + /// This is separate from the poptrie rebuild interval (see + /// [`Self::set_mrib_rpf_rebuild_interval`]). + pub fn set_mrib_rpf_revalidation_interval( + &self, + interval: std::time::Duration, + ) { + self.config + .rpf_revalidation_interval_ms + .store(interval.as_millis() as u64, Ordering::Relaxed); + } + + /// Get the RPF revalidation sweep interval (atomic, for revalidation). + pub fn get_mrib_rpf_revalidation_interval_ms(&self) -> Arc { + Arc::clone(&self.config.rpf_revalidation_interval_ms) + } + + /// Get the IPv4 loc-rib mutex (for revalidation). + pub fn rib4_loc(&self) -> Arc> { + Arc::clone(&self.rib4_loc) + } + + /// Get the IPv6 loc-rib mutex (for revalidation). + pub fn rib6_loc(&self) -> Arc> { + Arc::clone(&self.rib6_loc) + } + + /// Get the bestpath fanout atomic (for revalidation). + pub fn bestpath_fanout_atomic(&self) -> Arc { + Arc::clone(&self.config.bestpath_fanout) + } + + pub fn mrib(&self) -> &Mrib { + &self.mrib + } + + pub fn log(&self) -> &Logger { + &self.log + } + /// Register a routing databse watcher. pub fn watch(&self, tag: String, sender: Sender) { write_lock!(self.watchers).push(Watcher { tag, sender }); @@ -586,20 +754,12 @@ impl Db { rib_loc: &mut Rib4, prefix: &Prefix4, ) { - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { - match bestpaths(paths, fanout.get() as usize) { + match bestpaths(paths, fanout as usize) { // bestpath found at least 1 path for loc-rib Some(bp) => { rib_loc.insert(*prefix, bp.clone()); @@ -615,6 +775,11 @@ impl Db { rib_loc.remove(prefix); } } + + // Request RPF table rebuild (may be rate-limited). + // Pass the specific prefix for targeted (S,G) revalidation. + self.rpf_table + .trigger_rebuild_v4(Arc::clone(&self.rib4_loc), Some(*prefix)); } pub fn update_rib6_loc( @@ -623,20 +788,12 @@ impl Db { rib_loc: &mut Rib6, prefix: &Prefix6, ) { - let fanout = self.get_bestpath_fanout().unwrap_or_else(|e| { - rdb_log!( - self, - error, - "failed to get bestpath fanout: {e}"; - "unit" => UNIT_PERSISTENT - ); - NonZeroU8::new(DEFAULT_BESTPATH_FANOUT).unwrap() - }); + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); match rib_in.get(prefix) { // rib-in has paths worth evaluating for loc-rib Some(paths) => { - match bestpaths(paths, fanout.get() as usize) { + match bestpaths(paths, fanout as usize) { // bestpath found at least 1 path for loc-rib Some(bp) => { rib_loc.insert(*prefix, bp.clone()); @@ -652,6 +809,11 @@ impl Db { rib_loc.remove(prefix); } } + + // Request RPF table rebuild (may be rate-limited). + // Pass the specific prefix for targeted (S,G) revalidation. + self.rpf_table + .trigger_rebuild_v6(Arc::clone(&self.rib6_loc), Some(*prefix)); } // generic helper function to kick off a bestpath run for some @@ -1224,10 +1386,60 @@ impl Db { let tree = self.persistent.open_tree(SETTINGS)?; tree.insert(BESTPATH_FANOUT, &[fanout.get()])?; tree.flush()?; + + // Update cached atomic for RPF revalidator + self.config + .bestpath_fanout + .store(fanout.get(), Ordering::Relaxed); + self.trigger_bestpath_when(|_pfx, _paths| true); Ok(()) } + /// Get the minimum interval between poptrie cache rebuilds. + /// + /// This rate-limits how often the poptrie is rebuilt in response to + /// unicast RIB changes. When rate-limited, lookups fall back to linear + /// scan. This is separate from the revalidation sweep interval (see + /// [`Self::set_mrib_rpf_revalidation_interval`]). + pub fn get_mrib_rpf_rebuild_interval( + &self, + ) -> Result { + let tree = self.persistent.open_tree(SETTINGS)?; + let interval_ms = match tree.get(MRIB_RPF_REBUILD_INTERVAL)? { + None => DEFAULT_MRIB_RPF_REBUILD_INTERVAL_MS, + Some(value) => { + let bytes: [u8; 8] = (*value).try_into().map_err(|_| { + Error::DbValue(format!( + "invalid mrib_rpf_rebuild_interval value in db: expected 8 bytes, found {}", + value.len() + )) + })?; + u64::from_be_bytes(bytes) + } + }; + Ok(std::time::Duration::from_millis(interval_ms)) + } + + /// Set the minimum interval between poptrie cache rebuilds. + /// + /// This rate-limits how often the poptrie is rebuilt in response to + /// unicast RIB changes. When rate-limited, lookups fall back to linear + /// scan. + /// + /// This is persisted to the settings tree. + pub fn set_mrib_rpf_rebuild_interval( + &self, + interval: std::time::Duration, + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(SETTINGS)?; + let interval_ms = interval.as_millis() as u64; + tree.insert(MRIB_RPF_REBUILD_INTERVAL, &interval_ms.to_be_bytes())?; + tree.flush()?; + self.rpf_table.set_rebuild_interval(interval); + Ok(()) + } + pub fn mark_bgp_peer_stale(&self, peer: IpAddr) { // TODO(ipv6): call this just for enabled address-families. // no need to walk the full rib for an AF that isn't affected @@ -1272,19 +1484,280 @@ impl Db { }); } - pub fn slot(&self) -> Option { - match self.slot.read() { - Ok(v) => *v, + // ======================================================================== + // MRIB (Multicast RIB) functionality + // ======================================================================== + + /// Update `mrib_loc` by performing RPF verification for a multicast route. + /// + /// For a route to be promoted from `mrib_in` to `mrib_loc`, it must pass + /// Reverse Path Forwarding (RPF) checks: + /// - For (*,G) routes: always promoted (no source to verify) + /// - For (S,G) routes: derive the RPF neighbor from the unicast RIB. + /// If a route to the source exists, install with the derived neighbor. + /// Otherwise, remove from `mrib_loc`. + /// + /// Both cases use atomic operations to avoid races with concurrent route + /// updates (e.g., adding replication targets). + pub fn update_mrib_loc(&self, key: &MulticastRouteKey) { + // (*,G) always installs - no RPF check needed + let Some(source) = key.source() else { + self.mrib.promote_any_source(key); + return; + }; + + // (S,G): derive rpf_neighbor from unicast RIB + let fanout = self.config.bestpath_fanout.load(Ordering::Relaxed); + let rpf_neighbor = self.rpf_table.lookup( + source, + &self.rib4_loc, + &self.rib6_loc, + fanout as usize, + ); + + if rpf_neighbor.is_none() { + debug!( + self.log, + "deselecting (S,G) route: no unicast path to source"; + "key" => %key + ); + } + + // Atomically update mrib_in and mrib_loc + self.mrib.apply_rpf_result(key, rpf_neighbor); + } + + /// Revalidate (S,G) routes against the unicast RIB. + /// + /// When the unicast RIB changes, re-derive `rpf_neighbor` for affected + /// routes. If `event` is provided with a specific prefix, only routes + /// whose source falls within that prefix are revalidated (targeted + /// revalidation). Otherwise, all (S,G) routes are revalidated (full + /// sweep). + /// + /// Uses atomic operations to avoid races with concurrent route updates. + pub(crate) fn revalidate_mrib( + &self, + event: Option, + ) { + let fanout = self.bestpath_fanout_atomic().load(Ordering::Relaxed); + let rib4_loc = self.rib4_loc(); + let rib6_loc = self.rib6_loc(); + + // Get all (S,G) route keys for revalidation + let keys: Vec<_> = self + .mrib + .get_source_specific_keys() + .into_iter() + .filter_map(|key| { + let source = key.source()?; + // Targeted revalidation (skip routes not affected) + if let Some(ref evt) = event + && !evt.matches_source(source) + { + return None; + } + Some((key, source)) + }) + .collect(); + + for (key, source) in keys { + // Re-derive rpf_neighbor from current unicast RIB + let rpf_neighbor = self.rpf_table.lookup( + source, + &rib4_loc, + &rib6_loc, + fanout as usize, + ); + + if rpf_neighbor.is_none() { + debug!( + self.log, + "revalidation: deselecting (S,G) route, no unicast path"; + "key" => %key + ); + } + + // Atomically update mrib_in and mrib_loc + self.mrib.apply_rpf_result(&key, rpf_neighbor); + } + } + + /// Load persisted static multicast routes into `mrib_in` at startup. + /// + /// After loading each route, we perform RPF verification to promote + /// eligible routes to `mrib_loc`. This ensures routes are installed + /// immediately at startup rather than waiting for the next periodic sweep. + fn load_mcast_static_routes(&self) { + let tree = match self.persistent.open_tree(STATIC_MCAST_ROUTES) { + Ok(t) => t, Err(e) => { - error!(self.log, "unable to read switch slot"; "error" => %e); - None + error!( + self.log, + "failed to open static mcast routes tree: {e}" + ); + return; } + }; + + for result in tree.iter() { + let (_, value) = match result { + Ok(kv) => kv, + Err(e) => { + error!(self.log, "failed to read mcast route: {e}"); + continue; + } + }; + + let value = String::from_utf8_lossy(&value); + let route = match serde_json::from_str::(&value) { + Ok(r) => r, + Err(e) => { + error!(self.log, "failed to deserialize mcast route: {e}"); + continue; + } + }; + + let key = route.key; + if let Err(e) = self.mrib.add_route(route) { + error!( + self.log, + "failed to load mcast route: {e}"; + "key" => %key + ); + continue; + } + + // Perform RPF verification and promote to mrib_loc if eligible + self.update_mrib_loc(&key); } } - pub fn set_slot(&mut self, slot: Option) { - let mut value = self.slot.write().unwrap(); - *value = slot; + /// Add static multicast routes to the MRIB. + /// + /// Routes are persisted to disk and added to `mrib_in`. Then + /// `update_mrib_loc` derives `rpf_neighbor` from the unicast RIB and + /// promotes routes to `mrib_loc` if a valid path exists. + /// + /// Uses upsert semantics: existing routes with the same key are updated. + /// This enables idempotent calls from Nexus RPWs. + pub fn add_static_mcast_routes( + &self, + routes: &[MulticastRoute], + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + + for route in routes { + // Persist to disk (rpf_neighbor will be derived by `update_mrib_loc`) + let key = route.key.db_key()?; + let value = serde_json::to_string(route)?; + tree.insert(key.as_slice(), value.as_str())?; + + // Add to `mrib_in` + self.mrib.add_route(route.clone())?; + } + + tree.flush()?; + + // Derive rpf_neighbor and promote to `mrib_loc` + for route in routes { + self.update_mrib_loc(&route.key); + } + + Ok(()) + } + + /// Remove static multicast routes from the MRIB. + /// + /// Routes are removed from persistence and both `mrib_in` and `mrib_loc`. + pub fn remove_static_mcast_routes( + &self, + keys: &[MulticastRouteKey], + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + + for key in keys { + // Remove from persistence + let key_bytes = key.db_key()?; + tree.remove(key_bytes.as_slice())?; + + // Remove from mrib (both `mrib_in` and `mrib_loc`) + self.mrib.remove_route(key)?; + } + + tree.flush()?; + Ok(()) + } + + /// Get all static multicast routes from persistence. + pub fn get_static_mcast_routes( + &self, + ) -> Result, Error> { + let tree = self.persistent.open_tree(STATIC_MCAST_ROUTES)?; + let mut routes = Vec::new(); + + for result in tree.iter() { + let (_, value) = result?; + let value = String::from_utf8_lossy(&value); + let route: MulticastRoute = serde_json::from_str(&value)?; + routes.push(route); + } + + Ok(routes) + } + + /// Get a specific multicast route. + pub fn get_mcast_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + self.mrib.get_route(key) + } + + /// Get the full MRIB input table (all routes from all sources). + pub fn full_mrib(&self) -> crate::mrib::MribTable { + self.mrib.full_mrib() + } + + /// Get the local MRIB table (selected/installed routes). + pub fn loc_mrib(&self) -> crate::mrib::MribTable { + self.mrib.loc_mrib() + } + + /// List MRIB routes with filtering, cloning only matching entries. + /// + /// This is more efficient than `full_mrib()`/`loc_mrib()` when filtering + /// is needed, as it clones only the routes that match the filter. + /// + /// Parameters: + /// - `af`: Filter by address family (`None = all`) + /// - `static_only`: Filter by origin (`None = all`, `Some(true) = static`, + /// `Some(false) = dynamic`) + /// - `installed`: If true, query `mrib_loc`; otherwise `mrib_in` + pub fn mrib_list( + &self, + af: Option, + static_only: Option, + installed: bool, + ) -> Vec { + self.mrib.list_routes(af, static_only, installed) + } + + /// Get a specific multicast route from `mrib_loc` (selected/installed). + pub fn get_selected_mcast_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + self.mrib.get_selected_route(key) + } + + /// Register a watcher for MRIB changes. + pub fn watch_mrib( + &self, + tag: String, + sender: Sender, + ) { + self.mrib.watch(tag, sender); } } @@ -1316,37 +1789,45 @@ impl Reaper { } fn reap(self: &Arc) { - self.rib - .lock() - .unwrap() - .iter_mut() - .for_each(|(_prefix, paths)| { - paths.retain(|p| { - p.bgp - .as_ref() - .map(|b| { - b.stale - .map(|s| { - Utc::now().signed_duration_since(s) - < *lock!(self.stale_max) - }) - .unwrap_or(true) - }) - .unwrap_or(true) - }) - }); + lock!(self.rib).iter_mut().for_each(|(_prefix, paths)| { + paths.retain(|p| { + p.bgp + .as_ref() + .map(|b| { + b.stale + .map(|s| { + Utc::now().signed_duration_since(s) + < *lock!(self.stale_max) + }) + .unwrap_or(true) + }) + .unwrap_or(true) + }) + }); } } #[cfg(test)] mod test { use crate::{ - AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, - Prefix6, StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, + AddressFamily, DEFAULT_MULTICAST_VNI, DEFAULT_RIB_PRIORITY_STATIC, + Path, Prefix, Prefix4, Prefix6, StaticRouteKey, + db::Db, + test::{TEST_WAIT_ITERATIONS, TestDb}, + types::{ + MulticastAddr, MulticastAddrV4, MulticastAddrV6, MulticastRoute, + MulticastRouteKey, MulticastRouteSource, PrefixDbKey, + }, }; use mg_common::log::*; + use mg_common::test::DEFAULT_INTERVAL_MS; + use mg_common::wait_for; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str::FromStr; + use std::time::Duration; + + // Valid admin-scoped underlay address for tests + const TEST_UNDERLAY: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); fn get_test_db() -> TestDb { let log = init_file_logger("rib.log"); @@ -1619,6 +2100,293 @@ mod test { assert!(db.loc_rib(None).is_empty()); } + #[test] + fn test_mrib_revalidation_on_rib_change() { + // Inlined helper to test revalidation for a given address family. + // `rpf_neighbor` is derived from the unicast RIB. A route is + // selected when the unicast path exists and deselected when + // a unicast path is removed + fn test_af + Copy>( + db: &Db, + s_ip: IpAddr, + prefix: P, + nexthop: IpAddr, + group: MulticastAddr, + ) { + let srk = StaticRouteKey { + prefix: prefix.into(), + nexthop, + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[srk]).unwrap(); + + let key = MulticastRouteKey::new( + Some(s_ip), + group, + DEFAULT_MULTICAST_VNI, + ) + .expect("AF match"); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + db.add_static_mcast_routes(&[route]).unwrap(); + + // Initially should be selected + wait_for!( + db.get_selected_mcast_route(&key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "(S,G) was not selected initially" + ); + + // Verify `rpf_neighbor` was derived + let selected = db.get_selected_mcast_route(&key).unwrap(); + assert_eq!( + selected.rpf_neighbor, + Some(nexthop), + "rpf_neighbor should be derived from unicast RIB" + ); + + // Remove unicast route; MRIB should be de-selected + db.remove_static_routes(&[srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&key).is_none(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "(S,G) remained selected after unicast route removed" + ); + + // Re-add unicast route + db.add_static_routes(&[srk]).unwrap(); + + // MRIB should be selected again + wait_for!( + db.get_selected_mcast_route(&key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "(S,G) not re-selected after unicast route restored" + ); + + // Cleanup + db.remove_static_routes(&[srk]).unwrap(); + db.remove_static_mcast_routes(&[key]).unwrap(); + } + + let log = init_file_logger("mrib_reval.log"); + let db = + crate::test::get_test_db("mrib_reval", log).expect("create db"); + db.set_mrib_rpf_rebuild_interval(std::time::Duration::ZERO) + .unwrap(); + + // IPv4 + test_af( + &db, + IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), + "192.0.2.0/24".parse::().unwrap(), + IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"), + ); + + // IPv6 + test_af( + &db, + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 10)), + "2001:db8::/32".parse::().unwrap(), + IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); + } + + /// Test (*,G) vs (S,G) selection behavior. + /// + /// - (*,G) routes are always selected (no RPF check needed) + /// - (S,G) routes require a unicast route to the source for RPF + #[test] + fn test_mrib_any_source_vs_source_specific() { + let log = init_file_logger("mrib_asm_ssm.log"); + let db = + crate::test::get_test_db("mrib_asm_ssm", log).expect("create db"); + db.set_mrib_rpf_rebuild_interval(Duration::ZERO).unwrap(); + + // Case: (*,G) with ASM address goes to `mrib_loc` immediately + // (no unicast route needed) + let asm_group = + MulticastAddr::new_v4(225, 5, 5, 5).expect("valid mcast"); + let star_g_key = MulticastRouteKey::any_source(asm_group); + let star_g_route = MulticastRoute::new( + star_g_key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + db.add_static_mcast_routes(&[star_g_route]).unwrap(); + + // (*,G) should be in both `mrib_in` AND `mrib_loc` immediately + wait_for!( + db.get_selected_mcast_route(&star_g_key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "(*,G) should be in mrib_loc immediately" + ); + assert!( + db.get_mcast_route(&star_g_key).is_some(), + "(*,G) should also be in mrib_in" + ); + + // Case: (S,G) with SSM address (232.x) - requires unicast route + let ssm_group = MulticastAddrV4::new(Ipv4Addr::new(232, 1, 1, 1)) + .expect("valid mcast"); // SSM range + let source = Ipv4Addr::new(10, 0, 0, 100); + let sg_key = MulticastRouteKey::source_specific_v4(source, ssm_group); + let sg_route = MulticastRoute::new( + sg_key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + db.add_static_mcast_routes(&[sg_route]).unwrap(); + + // (S,G) should be in `mrib_in` but NOT in `mrib_loc` yet + assert!( + db.get_mcast_route(&sg_key).is_some(), + "(S,G) should be in mrib_in" + ); + assert!( + db.get_selected_mcast_route(&sg_key).is_none(), + "(S,G) should NOT be in mrib_loc without unicast route" + ); + + // Add unicast route to source, now (S,G) should be selected + let srk = StaticRouteKey { + prefix: "10.0.0.0/24".parse::().unwrap().into(), + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&sg_key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "(S,G) should be selected after adding unicast route" + ); + + // Case: IPv6 (*,G) with global scope - goes to `mrib_loc` immediately + let v6_group = + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 0x5555]) + .expect("valid mcast"); + let v6_star_g_key = MulticastRouteKey::any_source(v6_group); + let v6_star_g_route = MulticastRoute::new( + v6_star_g_key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + db.add_static_mcast_routes(&[v6_star_g_route]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&v6_star_g_key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "IPv6 (*,G) should be selected immediately" + ); + + // Case: IPv6 (S,G) with SSM address (ff3e::) + let v6_ssm_group = MulticastAddrV6::new(Ipv6Addr::new( + 0xff3e, 0, 0, 0, 0, 0, 0, 0x1234, + )) + .expect("valid mcast"); + let v6_source = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100); + let v6_sg_key = + MulticastRouteKey::source_specific_v6(v6_source, v6_ssm_group); + let v6_sg_route = MulticastRoute::new( + v6_sg_key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + db.add_static_mcast_routes(&[v6_sg_route]).unwrap(); + + // IPv6 (S,G) should be in `mrib_in` but NOT in `mrib_loc` yet + // (operations are synchronous, no sleep needed) + assert!( + db.get_mcast_route(&v6_sg_key).is_some(), + "IPv6 (S,G) should be in mrib_in" + ); + assert!( + db.get_selected_mcast_route(&v6_sg_key).is_none(), + "IPv6 (S,G) should NOT be in mrib_loc without unicast route" + ); + + // Add unicast route + let v6_srk = StaticRouteKey { + prefix: "2001:db8::/32".parse::().unwrap().into(), + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + vlan_id: None, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + }; + db.add_static_routes(&[v6_srk]).unwrap(); + + wait_for!( + db.get_selected_mcast_route(&v6_sg_key).is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "IPv6 (S,G) should be selected after adding unicast route" + ); + + // Cleanup + db.remove_static_routes(&[srk, v6_srk]).unwrap(); + db.remove_static_mcast_routes(&[ + star_g_key, + sg_key, + v6_star_g_key, + v6_sg_key, + ]) + .unwrap(); + } + + #[test] + fn test_mrib_static_persistence() { + let db_path = "/tmp/mrib_persist_test.db"; + let _ = std::fs::remove_dir_all(db_path); + + let group = MulticastAddr::new_v4(225, 2, 2, 2).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + // Create Db and add static multicast route + { + let log = init_file_logger("mrib_persist1.log"); + let db = Db::new(db_path, log).expect("create db"); + db.add_static_mcast_routes(std::slice::from_ref(&route)) + .expect("add static mcast route"); + assert_eq!(db.get_static_mcast_routes().unwrap().len(), 1); + assert!(db.get_mcast_route(&key).is_some()); + } + + // Reopen Db and verify route was loaded from persistence + { + let log = init_file_logger("mrib_persist2.log"); + let db = Db::new(db_path, log).expect("reopen db"); + assert_eq!(db.full_mrib().len(), 1); + assert!(db.get_mcast_route(&key).is_some()); + assert_eq!(db.get_static_mcast_routes().unwrap().len(), 1); + } + + // Cleanup + let _ = std::fs::remove_dir_all(db_path); + } + #[test] fn test_static_routing_ipv4_basic() { let db = get_test_db(); diff --git a/rdb/src/error.rs b/rdb/src/error.rs index e562a36e..560ff814 100644 --- a/rdb/src/error.rs +++ b/rdb/src/error.rs @@ -13,6 +13,9 @@ pub enum Error { #[error("serialization error {0}")] Serialization(#[from] serde_json::Error), + #[error("io error {0}")] + Io(#[from] std::io::Error), + #[error("db key error {0}")] DbKey(String), @@ -24,4 +27,10 @@ pub enum Error { #[error("Parsing error {0}")] Parsing(String), + + #[error("Not found: {0}")] + NotFound(String), + + #[error("Validation error: {0}")] + Validation(String), } diff --git a/rdb/src/lib.rs b/rdb/src/lib.rs index 58a494f0..a66f5fb0 100644 --- a/rdb/src/lib.rs +++ b/rdb/src/lib.rs @@ -3,9 +3,11 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. pub mod db; +pub mod mrib; pub mod types; pub use db::Db; +pub use mrib::Mrib; pub use types::*; pub mod bestpath; pub mod error; diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs new file mode 100644 index 00000000..b3e2ac27 --- /dev/null +++ b/rdb/src/mrib/mod.rs @@ -0,0 +1,702 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Multicast Routing Information Base (MRIB). +//! +//! The MRIB manages in-memory multicast routing state, including: +//! - (*,G) entries (any-source multicast) +//! - (S,G) entries (source-specific multicast) +//! - Replication targets (local interfaces and remote nexthops) +//! - TODO: IGMP/MLD-learned routes (dynamic) +//! +//! ## Lock Ordering +//! +//! When acquiring multiple locks, we acquire them in this ordering: +//! 1. `mrib_in` +//! 2. `mrib_loc` +//! 3. `watchers` + +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::net::{IpAddr, Ipv6Addr}; +use std::sync::atomic::Ordering; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread; +use std::time::Duration; + +use slog::{Logger, error, info}; + +use mg_common::{lock, read_lock, write_lock}; + +use crate::error::Error; +use crate::types::{ + AddressFamily, MribChangeNotification, MulticastAddr, MulticastRoute, + MulticastRouteKey, MulticastRouteSource, +}; + +pub mod rpf; + +// Re-export from rpf module +pub use rpf::DEFAULT_REVALIDATION_INTERVAL; + +/// The MRIB table type: maps multicast route keys to route entries. +/// Each entry maps a [MulticastRouteKey] to a [MulticastRoute]. +pub type MribTable = BTreeMap; + +/// The Multicast Routing Information Base. +/// +/// Pure in-memory multicast routing tables, matching the unicast RIB pattern. +/// Persistence is handled by [`crate::Db`]. +/// +/// The MRIB maintains two tables: +/// - `mrib_in`: All multicast routes from all sources (static, IGMP) +/// - `mrib_loc`: Selected routes that pass Reverse Path Forwarding (RPF) +/// checks and are installed in the data plane. +/// +/// Note: `(*,G)` routes have no source address, so they always pass +/// to `mrib_loc` immediately (RPF only applies to `(S,G)` routes). +#[derive(Clone)] +pub struct Mrib { + /// All multicast routes from all sources (static, IGMP). + mrib_in: Arc>, + + /// Selected multicast routes that have passed RPF verification. + mrib_loc: Arc>, + + /// Watchers notified of MRIB changes. + watchers: Arc>>, + + log: Logger, +} + +#[derive(Clone)] +struct MribWatcher { + tag: String, + sender: Sender, +} + +impl Mrib { + pub fn new(log: Logger) -> Self { + Self { + mrib_in: Arc::new(Mutex::new(MribTable::new())), + mrib_loc: Arc::new(Mutex::new(MribTable::new())), + watchers: Arc::new(RwLock::new(Vec::new())), + log, + } + } + + /// Register a watcher for MRIB changes. + pub fn watch(&self, tag: String, sender: Sender) { + write_lock!(self.watchers).push(MribWatcher { tag, sender }); + } + + /// Remove a watcher by tag. + pub fn unwatch(&self, tag: &str) { + write_lock!(self.watchers).retain(|w| w.tag != tag); + } + + /// Notify all watchers of MRIB changes. + /// + /// Automatically removes watchers whose channels have been closed. + /// + /// This function releases the lock before sending to avoid potential + /// deadlocks if a watcher's receiver calls back into the MRIB. + fn notify(&self, n: MribChangeNotification) { + // Snapshot watchers under lock, then release before sending + let snapshot: Vec<_> = + read_lock!(self.watchers).iter().cloned().collect(); + + // Send to all watchers (lock released, no deadlock risk) + let mut dead_tags = Vec::new(); + for MribWatcher { tag, sender } in &snapshot { + if let Err(e) = sender.send(n.clone()) { + error!(self.log, "watcher '{tag}' disconnected, removing: {e}"); + dead_tags.push(tag.clone()); + } + } + + // Remove dead watchers + if !dead_tags.is_empty() { + write_lock!(self.watchers).retain(|w| !dead_tags.contains(&w.tag)); + } + } + + /// Get a copy of the full MRIB input table (all routes from all sources). + pub fn full_mrib(&self) -> MribTable { + lock!(self.mrib_in).clone() + } + + /// Get a copy of the local MRIB table (selected/installed routes). + pub fn loc_mrib(&self) -> MribTable { + lock!(self.mrib_loc).clone() + } + + /// List routes with filtering, cloning only matching entries. + /// + /// Arguments: + /// - `af`: Filter by address family (`None = all`) + /// - `static_only`: Filter by origin (`None = all`, `Some(true) = static`, + /// Some(false) = dynamic) + /// - `installed`: If true, query `mrib_loc`; otherwise `mrib_in` + pub fn list_routes( + &self, + af: Option, + static_only: Option, + installed: bool, + ) -> Vec { + let filter = |route: &&MulticastRoute| -> bool { + // Address family filter + let af_match = match af { + None => true, + Some(AddressFamily::Ipv4) => { + matches!(route.key.group(), MulticastAddr::V4(_)) + } + Some(AddressFamily::Ipv6) => { + matches!(route.key.group(), MulticastAddr::V6(_)) + } + }; + // Origin filter + let origin_match = match static_only { + None => true, + Some(true) => { + matches!(route.source, MulticastRouteSource::Static) + } + Some(false) => { + !matches!(route.source, MulticastRouteSource::Static) + } + }; + af_match && origin_match + }; + + if installed { + lock!(self.mrib_loc) + .values() + .filter(filter) + .cloned() + .collect() + } else { + lock!(self.mrib_in) + .values() + .filter(filter) + .cloned() + .collect() + } + } + + /// Get a specific multicast route from `mrib_in`. + /// + /// Returns a cloned [MulticastRoute], if present. + pub fn get_route(&self, key: &MulticastRouteKey) -> Option { + lock!(self.mrib_in).get(key).cloned() + } + + /// Get a specific multicast route from `mrib_loc` (selected/installed). + /// + /// Returns a cloned [MulticastRoute], if present. + pub fn get_selected_route( + &self, + key: &MulticastRouteKey, + ) -> Option { + lock!(self.mrib_loc).get(key).cloned() + } + + /// Atomically promote a (*,G) route from `mrib_in` to `mrib_loc`. + /// + /// (*,G) routes have no source address, so they always pass RPF checks. + /// This method atomically copies the fresh route data to `mrib_loc`, + /// avoiding races with concurrent route updates. + /// + /// Returns `true` if the route was found and promoted. + pub(crate) fn promote_any_source(&self, key: &MulticastRouteKey) -> bool { + let changed = { + let mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + let Some(route) = mrib_in.get(key) else { + return false; + }; + + match mrib_loc.entry(*key) { + Entry::Occupied(mut e) => { + let unchanged = e.get().rpf_neighbor == route.rpf_neighbor + && e.get().underlay_group == route.underlay_group + && e.get().underlay_nexthops == route.underlay_nexthops + && e.get().source == route.source; + if !unchanged { + e.insert(route.clone()); + } + !unchanged + } + Entry::Vacant(e) => { + e.insert(route.clone()); + true + } + } + }; + + if changed { + self.notify(MribChangeNotification::from(*key)); + } + true + } + + /// Apply an RPF verification result atomically for (S,G) routes. + /// + /// Updates `rpf_neighbor` in `mrib_in` (so API queries show the derived + /// neighbor) and then promotes/removes the fresh route to/from `mrib_loc`. + /// + /// By holding both locks and re-fetching from `mrib_in`, we avoid a race + /// where concurrent route updates (e.g., adding underlay nexthops) could + /// be lost if we used a stale snapshot. + /// + /// Only notifies watchers if `mrib_loc` actually changed. + pub(crate) fn apply_rpf_result( + &self, + key: &MulticastRouteKey, + neighbor: Option, + ) { + let changed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + match mrib_in.get_mut(key) { + None => { + // Route removed from mrib_in, ensure gone from mrib_loc + mrib_loc.remove(key).is_some() + } + Some(route) => { + // Update rpf_neighbor in mrib_in + route.rpf_neighbor = neighbor; + + // Promote or remove from mrib_loc based on RPF result + if neighbor.is_some() { + match mrib_loc.entry(*key) { + Entry::Occupied(mut e) => { + let unchanged = e.get().rpf_neighbor + == route.rpf_neighbor + && e.get().underlay_group + == route.underlay_group + && e.get().underlay_nexthops + == route.underlay_nexthops + && e.get().source == route.source; + if !unchanged { + e.insert(route.clone()); + } + !unchanged + } + Entry::Vacant(e) => { + e.insert(route.clone()); + true + } + } + } else { + // No unicast route to source -> remove from mrib_loc + mrib_loc.remove(key).is_some() + } + } + } + }; + + if changed { + self.notify(MribChangeNotification::from(*key)); + } + } + + /// Add or update a multicast route in `mrib_in`. + /// + /// The route is added to `mrib_in` only. The caller (`Db`) is responsible + /// for calling [`crate::Db::update_mrib_loc()`] to perform RPF verification + /// and potentially promote the route to `mrib_loc`. + /// + /// Accepts a full [MulticastRoute]. + pub fn add_route(&self, route: MulticastRoute) -> Result<(), Error> { + let key = route.key; + let changed = { + let mut mrib_in = lock!(self.mrib_in); + let changed = match mrib_in.get(&key) { + Some(existing) => { + // Check if route actually changed + existing.underlay_nexthops != route.underlay_nexthops + || existing.rpf_neighbor != route.rpf_neighbor + || existing.source != route.source + || existing.underlay_group != route.underlay_group + } + None => true, // New route + }; + mrib_in.insert(key, route); + changed + }; + + if changed { + self.notify(MribChangeNotification::from(key)); + } + Ok(()) + } + + /// Remove a multicast route from both `mrib_in` and `mrib_loc`. + pub fn remove_route(&self, key: &MulticastRouteKey) -> Result { + // Acquire both locks following documented order to ensure atomicity + let removed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + let removed_in = mrib_in.remove(key).is_some(); + let removed_loc = mrib_loc.remove(key).is_some(); + removed_in || removed_loc + }; + + if removed { + self.notify(MribChangeNotification::from(*key)); + } + Ok(removed) + } + + /// Add a replication target to an existing route in both `mrib_in` and + /// `mrib_loc`. + pub fn add_target( + &self, + key: &MulticastRouteKey, + target: Ipv6Addr, + ) -> Result<(), Error> { + // Acquire both locks following documented order to ensure atomicity + { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + if let Some(route) = mrib_in.get_mut(key) { + route.add_target(target); + } else { + return Err(Error::NotFound(format!( + "multicast route {key} not found", + ))); + } + + if let Some(route) = mrib_loc.get_mut(key) { + route.add_target(target); + } + } + + self.notify(MribChangeNotification::from(*key)); + Ok(()) + } + + /// Remove a replication target from an existing route in both `mrib_in` and + /// `mrib_loc`. + pub fn remove_target( + &self, + key: &MulticastRouteKey, + target: &Ipv6Addr, + ) -> Result { + // Acquire both locks following documented order to ensure atomicity + let removed = { + let mut mrib_in = lock!(self.mrib_in); + let mut mrib_loc = lock!(self.mrib_loc); + + let removed_in = if let Some(route) = mrib_in.get_mut(key) { + route.remove_target(target) + } else { + return Err(Error::NotFound(format!( + "multicast route {key} not found", + ))); + }; + + let removed_loc = if let Some(route) = mrib_loc.get_mut(key) { + route.remove_target(target) + } else { + false + }; + + removed_in || removed_loc + }; + + if removed { + self.notify(MribChangeNotification::from(*key)); + } + Ok(removed) + } + + /// Get all routes for a specific multicast group from `mrib_in`. + pub fn get_routes_for_group( + &self, + group: &MulticastAddr, + ) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| &route.key.group() == group) + .cloned() + .collect() + } + + /// Get all routes with a specific source from `mrib_in`. + pub fn get_routes_for_source( + &self, + source: &IpAddr, + ) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| route.key.source().as_ref() == Some(source)) + .cloned() + .collect() + } + + /// Get all any-source (*,G) routes from `mrib_in`. + pub fn get_any_source_routes(&self) -> Vec { + lock!(self.mrib_in) + .values() + .filter(|route| route.key.source().is_none()) + .cloned() + .collect() + } + + /// Get keys for all source-specific (S,G) routes. + pub fn get_source_specific_keys(&self) -> Vec { + lock!(self.mrib_in) + .keys() + .filter(|key| key.source().is_some()) + .copied() + .collect() + } +} + +/// Spawn the RPF revalidator background thread. +/// +/// Listens for RPF cache rebuild events and re-checks RPF validity for all +/// source-specific (S,G) multicast routes. Routes that pass RPF validation +/// are installed in `mrib_loc`, while routes that fail are removed. +/// +/// Returns the sender for rebuild events if spawn succeeded, `None` if failed. +/// The caller should only install the notifier in `RpfTable` if this returns +/// `Some`, ensuring the channel receiver is actually running. +pub(crate) fn spawn_rpf_revalidator( + db: crate::Db, +) -> Option> { + let err_log = db.log().clone(); + let sweep_interval_ms = db.get_mrib_rpf_revalidation_interval_ms(); + let (tx, rx) = mpsc::channel::(); + + match thread::Builder::new() + .name("rpf-revalidator".to_string()) + .spawn(move || { + loop { + let ms = sweep_interval_ms.load(Ordering::Relaxed); + let timeout = if ms == 0 { + DEFAULT_REVALIDATION_INTERVAL + } else { + Duration::from_millis(ms) + }; + + // Wait for an event or timeout + let first_event = match rx.recv_timeout(timeout) { + Ok(evt) => Some(evt), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }; + + // Drain any queued events to avoid redundant full MRIB scans + // when many events arrive in quick succession. + let mut extra_events = 0usize; + while rx.try_recv().is_ok() { + extra_events += 1; + } + + // If we coalesced multiple events, do a full sweep. + // Otherwise use the specific event for targeted revalidation. + let event = if extra_events > 0 { None } else { first_event }; + db.revalidate_mrib(event); + } + info!(db.log(), "rpf revalidator shutting down"); + }) { + Ok(_) => Some(tx), + Err(e) => { + error!(err_log, "failed to spawn rpf-revalidator: {e}"); + None + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + use std::net::Ipv4Addr; + + use mg_common::log::*; + + use crate::types::{MulticastAddrV4, MulticastAddrV6}; + + // Valid admin-scoped underlay address for tests + const TEST_UNDERLAY: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); + + #[test] + fn test_mrib_basic() { + let log = init_file_logger("mrib_test.log"); + let mrib = Mrib::new(log); + + // Test ASM route (*,G) + let group = MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + assert!(mrib.get_route(&key).is_some()); + + // Test source-specific multicast route (S,G) + let source = Ipv4Addr::new(10, 0, 0, 1); + let group_v4 = MulticastAddrV4::new(Ipv4Addr::new(225, 1, 1, 1)) + .expect("valid mcast"); + let key_sg = MulticastRouteKey::source_specific_v4(source, group_v4); + let mut route_sg = MulticastRoute::new( + key_sg, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + // Add replication targets + let target1 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let target2 = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2); + + route_sg.add_target(target1); + route_sg.add_target(target2); + + mrib.add_route(route_sg.clone()).expect("add S,G route"); + assert_eq!(mrib.full_mrib().len(), 2); + + // Test queries + let group_routes = mrib.get_routes_for_group(&group); + assert_eq!(group_routes.len(), 2); + + let any_source = mrib.get_any_source_routes(); + assert_eq!(any_source.len(), 1); + + let source_specific = mrib.get_source_specific_keys(); + assert_eq!(source_specific.len(), 1); + + // Test removal + mrib.remove_route(&key).expect("remove *,G route"); + assert_eq!(mrib.full_mrib().len(), 1); + assert!(mrib.get_route(&key).is_none()); + } + + #[test] + fn test_mrib_watchers() { + use std::sync::mpsc::channel; + + let log = init_file_logger("mrib_watcher_test.log"); + let mrib = Mrib::new(log); + + // Register watcher + let (tx, rx) = channel(); + mrib.watch("test-watcher".to_string(), tx); + + // Add a route and verify notification + let group = MulticastAddr::new_v4(225, 3, 3, 3).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + + // Should receive notification + let notification = rx.recv().expect("receive notification"); + assert_eq!(notification.changed.len(), 1); + assert!(notification.changed.contains(&key)); + + // Remove route and verify notification + mrib.remove_route(&key).expect("remove route"); + + let notification = rx.recv().expect("receive notification"); + assert_eq!(notification.changed.len(), 1); + assert!(notification.changed.contains(&key)); + } + + #[test] + fn test_mrib_in_vs_loc() { + let log = init_file_logger("mrib_in_loc_test.log"); + let mrib = Mrib::new(log); + + // Add a (*,G) route to mrib_in only + let group = MulticastAddr::new_v4(225, 4, 4, 4).expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + mrib.add_route(route.clone()).expect("add route"); + + // Verify route exists in `mrib_in` but not in `mrib_loc` + assert_eq!(mrib.full_mrib().len(), 1); + assert_eq!(mrib.loc_mrib().len(), 0); + assert!(mrib.get_route(&key).is_some()); + assert!(mrib.get_selected_route(&key).is_none()); + + // Promote (*,G) route to `mrib_loc` + assert!(mrib.promote_any_source(&key)); + + // Now verify route exists in both tables + assert_eq!(mrib.full_mrib().len(), 1); + assert_eq!(mrib.loc_mrib().len(), 1); + assert!(mrib.get_route(&key).is_some()); + assert!(mrib.get_selected_route(&key).is_some()); + + // Remove route completely (from both tables) + mrib.remove_route(&key).expect("remove route"); + assert_eq!(mrib.full_mrib().len(), 0); + assert_eq!(mrib.loc_mrib().len(), 0); + assert!(mrib.get_route(&key).is_none()); + assert!(mrib.get_selected_route(&key).is_none()); + } + + #[test] + fn test_mrib_ipv6_groups() { + let log = init_file_logger("mrib_v6_test.log"); + let mrib = Mrib::new(log); + + // IPv6 ASM route (*,G) + let group = MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"); + let key = MulticastRouteKey::any_source(group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + mrib.add_route(route.clone()).expect("add v6 route"); + assert!(mrib.get_route(&key).is_some()); + + // IPv6 source-specific multicast route (S,G) + let source = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1); + let group_v6 = + MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid mcast"); + let key_sg = MulticastRouteKey::source_specific_v6(source, group_v6); + let route_sg = MulticastRoute::new( + key_sg, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + + mrib.add_route(route_sg).expect("add v6 S,G route"); + assert_eq!(mrib.full_mrib().len(), 2); + + // Verify address family filtering + let v6_routes: Vec<_> = + mrib.get_routes_for_group(&group).into_iter().collect(); + assert_eq!(v6_routes.len(), 2); + + // Cleanup + mrib.remove_route(&key).expect("remove *,G"); + mrib.remove_route(&key_sg).expect("remove S,G"); + assert_eq!(mrib.full_mrib().len(), 0); + } +} diff --git a/rdb/src/mrib/rpf.rs b/rdb/src/mrib/rpf.rs new file mode 100644 index 00000000..4b8ed6cb --- /dev/null +++ b/rdb/src/mrib/rpf.rs @@ -0,0 +1,1150 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! [Reverse Path Forwarding][RPF] (RPF) verification for multicast routing. +//! +//! RPF verification ensures that multicast packets arrive from the expected +//! upstream direction, preventing loops in multicast distribution trees. +//! See [RFD 488] for the overall multicast routing design. +//! +//! This module provides an optimized RPF implementation using Oxide's +//! [poptrie] implementation for O(1) longest-prefix matching (LPM), with a lazy +//! rebuild strategy and fallback to linear scan during rebuilds. RPF lookups +//! happen frequently during multicast route installation and unicast RIB +//! changes, requiring LPM against the unicast RIB. +//! +//! ## (S,G) vs (*,G) Routes +//! +//! RPF verification only applies to (S,G) routes where a specific source +//! address is known. The source address is looked up in the unicast RIB to +//! find the expected upstream neighbor(s). +//! +//! (*,G) routes have no source address to verify, so RPF is skipped entirely +//! and routes are directly "installed." +//! +//! ## Revalidator Integration +//! +//! The RPF revalidator (spawned in `db.rs`) listens for rebuild events and +//! re-checks (S,G) routes when unicast RIB changes. Lock ordering: +//! +//! 1. Revalidator reads unicast RIB (`rib4_loc`/`rib6_loc`) +//! 2. Revalidator writes MRIB (`mrib_in`/`mrib_loc`) +//! +//! This matches the lock order in `mrib/mod.rs`. RPF lookups hold at most one +//! lock at a time: they try poptrie first (read lock), release it, then fall +//! back to linear scan (RIB lock) if needed. No path holds both locks. +//! +//! ## Lock Poisoning +//! +//! The poptrie cache uses asymmetric poison handling: +//! +//! - **Write side** (background rebuild thread): Panics on poison via +//! `write_lock!`. +//! +//! - **Read side**: Uses `.ok()` for graceful fallback to linear +//! scan if the cache is poisoned. This avoids crashing during RPF checks +//! while the linear scan fallback remains intact. +//! +//! Once an `RwLock` is poisoned, it **cannot be unpoisoned**. +//! Subsequent rebuild attempts will also panic on `write_lock!`, so the cache +//! remains permanently disabled. Reads continue to work via the +//! linear-scan fallback, keeping the system functional until SMF restarts the +//! daemon (which is the normal recovery path here). +//! +//! ## Threading Model +//! +//! Poptrie cache rebuilds run in short-lived, named background threads +//! ("rpf-poptrie-v4"/"rpf-poptrie-v6"). These threads are fire-and-forget: +//! we deliberately drop their `JoinHandle`s. If a rebuild thread panics, the +//! cache is simply not updated and RPF verification transparently falls back +//! to the linear-scan path until the next successful rebuild. +//! +//! [RPF]: https://datatracker.ietf.org/doc/html/rfc5110 +//! [RFD 488]: https://rfd.shared.oxide.computer/rfd/0488 +//! [poptrie]: https://conferences.sigcomm.org/sigcomm/2015/pdf/papers/p57.pdf + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::atomic::{AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use poptrie::Poptrie; +use slog::{Logger, debug, error}; + +use mg_common::{lock, write_lock}; + +use crate::bestpath::bestpaths; +use crate::db::{Rib4, Rib6}; +use crate::types::{Path, Prefix, PrefixContains}; +use crate::{Prefix4, Prefix6}; + +/// Default interval for periodic RPF revalidation sweeps. +pub const DEFAULT_REVALIDATION_INTERVAL: Duration = Duration::from_secs(60); + +/// Event emitted when RPF revalidation is needed. +/// +/// This is emitted when a poptrie rebuild completes, or when a rebuild +/// request was rate-limited but multicast RPF revalidation should still +/// proceed using the linear-scan fallback. +/// +/// The optional prefix ([`Prefix4`]/[`Prefix6`]) indicates which unicast route +/// changed, enabling targeted (S,G) revalidation. If `None`, a full sweep is +/// performed. +#[derive(Clone, Copy, Debug)] +pub(crate) enum RebuildEvent { + /// IPv4 unicast routing changed. If a prefix is provided, only (S,G) + /// routes with sources matching that prefix need revalidation. + V4(Option), + /// IPv6 unicast routing changed. If a prefix is provided, only (S,G) + /// routes with sources matching that prefix need revalidation. + V6(Option), +} + +impl RebuildEvent { + /// Convert to a full-sweep event (no specific prefix). + /// + /// Used when multiple prefixes may have changed during pending rebuilds. + fn to_full_sweep(self) -> Self { + match self { + Self::V4(_) => Self::V4(None), + Self::V6(_) => Self::V6(None), + } + } + + /// Check if a source address is potentially affected by this event. + /// + /// Returns true if the source falls within the changed prefix (targeted), + /// or if no specific prefix is provided (full sweep). + pub(crate) fn matches_source(&self, source: IpAddr) -> bool { + match (source, self) { + (src, RebuildEvent::V4(Some(prefix))) => { + Prefix::V4(*prefix).contains(src).is_some() + } + (src, RebuildEvent::V6(Some(prefix))) => { + Prefix::V6(*prefix).contains(src).is_some() + } + // No specific prefix = full sweep for this AF + (IpAddr::V4(_), RebuildEvent::V4(None)) => true, + (IpAddr::V6(_), RebuildEvent::V6(None)) => true, + // Wrong AF = skip + (IpAddr::V4(_), RebuildEvent::V6(_)) => false, + (IpAddr::V6(_), RebuildEvent::V4(_)) => false, + } + } +} + +/// Set of paths for a prefix, stored in the poptrie cache. +/// +/// We store full [`Path`] objects (not just nexthops) so that we can apply +/// bestpath selection at lookup time. This ensures consistent behavior +/// between the poptrie fast path and linear scan fallback, regardless of +/// the configured fanout value. +pub(crate) type CachedPaths = BTreeSet; + +/// State machine for coordinating poptrie rebuilds. +#[repr(u8)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RebuildState { + /// No rebuild in progress. + Idle = 0, + /// Rebuild thread is running. + Running = 1, + /// Rebuild thread is running and more changes arrived. + RunningPending = 2, +} + +/// Coordinator for poptrie rebuild threads. +/// +/// Provides atomic state transitions to prevent race conditions where +/// pending work could be missed between checking the pending flag and +/// releasing the in-progress lock. +#[derive(Debug)] +struct RebuildCoordinator(AtomicU8); + +impl RebuildCoordinator { + /// Create a new coordinator in the idle state. + fn new() -> Self { + Self(AtomicU8::new(RebuildState::Idle as u8)) + } + + /// Try to start a rebuild. Returns `true` if this thread should do work. + /// + /// Atomically transitions `Idle → Running`. + fn try_start(&self) -> bool { + self.0 + .compare_exchange( + RebuildState::Idle as u8, + RebuildState::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// Signal that more work arrived while a rebuild is in progress. + /// + /// Atomically transitions `Running → RunningPending`. If already + /// `RunningPending` or `Idle`, this is a no-op. + fn signal_pending(&self) { + // Only transition Running → RunningPending + let _ = self.0.compare_exchange( + RebuildState::Running as u8, + RebuildState::RunningPending as u8, + Ordering::AcqRel, + Ordering::Relaxed, + ); + } + + /// Check if more work is pending and atomically clear the pending flag. + /// + /// Returns `true` if we should continue working (previously `RunningPending`). + /// Atomically transitions `RunningPending → Running`. + fn check_pending(&self) -> bool { + self.0 + .compare_exchange( + RebuildState::RunningPending as u8, + RebuildState::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_ok() + } + + /// Mark the rebuild as complete. + /// + /// Transitions to `Idle`. Should only be called by the rebuild thread. + fn finish(&self) { + self.0.store(RebuildState::Idle as u8, Ordering::Release); + } +} + +/// Scope guard to mark rebuild as finished on drop. +/// +/// This ensures the coordinator transitions to `Idle` even if the rebuild +/// thread panics, preventing deadlock. +struct RebuildGuard(Arc); + +impl Drop for RebuildGuard { + fn drop(&mut self) { + self.0.finish(); + } +} + +/// Address-family related rebuild job. +/// +/// Encapsulates the RIB source and cache destination for a poptrie rebuild, +/// allowing the rebuild logic to be shared between IPv4 and IPv6. +enum RebuildJob { + V4 { + rib: Arc>, + cache: Arc>>>, + }, + V6 { + rib: Arc>, + cache: Arc>>>, + }, +} + +impl RebuildJob { + /// Take a snapshot of the RIB and build a fresh poptrie cache. + fn rebuild(&self) { + match self { + Self::V4 { rib, cache } => { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) + }; + let mut table = poptrie::Ipv4RoutingTable::default(); + for (addr, len, paths) in snapshot { + table.insert((addr, len), paths); + } + *write_lock!(cache) = Some(Poptrie::from(table)); + } + Self::V6 { rib, cache } => { + let snapshot = { + let r = lock!(rib); + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) + }; + let mut table = poptrie::Ipv6RoutingTable::default(); + for (addr, len, paths) in snapshot { + table.insert((addr, len), paths); + } + *write_lock!(cache) = Some(Poptrie::from(table)); + } + } + } + + /// Thread name for debugging. + fn thread_name(&self) -> &'static str { + match self { + Self::V4 { .. } => "rpf-poptrie-v4", + Self::V6 { .. } => "rpf-poptrie-v6", + } + } +} + +/// RPF verification table using poptrie for O(1) LPM (longest-prefix matching). +/// +/// This table maintains a poptrie-based cache of the RIB for fast RPF lookups. +/// The cache is rebuilt asynchronously in the background when triggered, with +/// rate limiting to avoid excessive rebuilds. Falls back to linear scan during +/// rebuilds or if poptrie is unavailable. +/// +/// The poptrie stores full [`Path`] objects (not just nexthops) so that +/// bestpath selection can be applied at lookup time. This ensures consistent +/// RPF verification behavior regardless of whether poptrie or linear scan is +/// used. +#[derive(Clone)] +pub(crate) struct RpfTable { + /// IPv4 poptrie cache. + cache_v4: Arc>>>, + /// IPv6 poptrie cache. + cache_v6: Arc>>>, + /// Last rebuild completion time for rate limiting. + /// + /// Shared between v4 and v6 rebuilds. During route updates + /// affecting both address families, this prevents simultaneous + /// rebuilds and spreads the CPU load. The fallback is a linear scan. + last_rebuild: Arc>>, + /// Configurable minimum interval between rebuilds (milliseconds). + rebuild_interval_ms: Arc, + /// Optional notifier for rebuild-complete events. + rebuild_notifier: Arc>>>, + /// Coordinator for IPv4 poptrie rebuilds. + rebuild_v4: Arc, + /// Coordinator for IPv6 poptrie rebuilds. + rebuild_v6: Arc, + /// Logger for error reporting. + log: Logger, +} + +impl RpfTable { + /// Default minimum time between rebuilds (milliseconds). + const DEFAULT_REBUILD_INTERVAL_MS: u64 = 1000; + + /// Create a new empty RPF table with default rebuild interval. + pub fn new(log: Logger) -> Self { + Self { + cache_v4: Arc::new(RwLock::new(None)), + cache_v6: Arc::new(RwLock::new(None)), + last_rebuild: Arc::new(Mutex::new(None)), + rebuild_interval_ms: Arc::new(AtomicU64::new( + Self::DEFAULT_REBUILD_INTERVAL_MS, + )), + rebuild_notifier: Arc::new(Mutex::new(None)), + rebuild_v4: Arc::new(RebuildCoordinator::new()), + rebuild_v6: Arc::new(RebuildCoordinator::new()), + log, + } + } + + /// Set the minimum interval between rebuilds. + pub fn set_rebuild_interval(&self, interval: Duration) { + self.rebuild_interval_ms + .store(interval.as_millis() as u64, Ordering::Relaxed); + } + + /// Check if enough time has passed since the last rebuild. + /// Returns `true` if rebuild should proceed, `false` if rate limited. + fn check_rate_limit(&self) -> bool { + let min_interval_ms = self.rebuild_interval_ms.load(Ordering::Relaxed); + let min_interval = Duration::from_millis(min_interval_ms); + + if let Ok(last) = self.last_rebuild.lock() + && let Some(last_instant) = *last + && last_instant.elapsed() < min_interval + { + return false; // Skip rebuild, too soon + } + true + } + + /// Send a rebuild event notification if configured. + fn notify(&self, event: RebuildEvent) { + if let Ok(guard) = self.rebuild_notifier.lock() + && let Some(tx) = &*guard + && tx.send(event).is_err() + { + debug!(self.log, "rpf revalidator not running"); + } + } + + /// Snapshot a RIB for poptrie rebuild. + /// + /// Extracts (addr_bits, prefix_len, paths) tuples from the RIB. + /// The `to_bits` closure converts the prefix to address bits. + fn snapshot_rib( + rib: &BTreeMap>, + to_bits: F, + ) -> Vec<(A, u8, BTreeSet)> + where + F: Fn(&P) -> (A, u8), + { + rib.iter() + .filter(|(_, paths)| !paths.is_empty()) + .map(|(prefix, paths)| { + let (bits, len) = to_bits(prefix); + (bits, len, paths.clone()) + }) + .collect() + } + + /// Install a notifier to be called on rebuild completion. + pub fn set_rebuild_notifier(&self, tx: mpsc::Sender) { + if let Ok(mut guard) = self.rebuild_notifier.lock() { + *guard = Some(tx); + } + } + + /// Spawn a background thread to execute a rebuild job. + /// + /// This is the shared implementation for both IPv4 and IPv6 rebuilds. + /// The job encapsulates the address-family-specific parts (RIB, cache), + /// while this method handles the shared logic (coordinator, timing, notify). + /// + /// The `event` parameter is used for targeted revalidation: if we complete + /// without looping, we send the original prefix so only affected (S,G) + /// routes are re-checked. If pending changes caused us to loop, we send + /// a full-sweep event (`None` prefix) since multiple prefixes may have + /// changed. + fn spawn_rebuild( + &self, + job: RebuildJob, + coordinator: Arc, + event: RebuildEvent, + ) { + let last_rebuild = self.last_rebuild.clone(); + let notifier = self.rebuild_notifier.clone(); + let log = self.log.clone(); + let thread_name = job.thread_name().to_string(); + let thread_coord = Arc::clone(&coordinator); + + if let Err(e) = + thread::Builder::new() + .name(thread_name.clone()) + .spawn(move || { + let _guard = RebuildGuard(Arc::clone(&thread_coord)); + + // Track whether we looped due to pending changes. + let mut looped = false; + + // Loop while there are pending rebuilds. This ensures we + // capture all RIB changes that occurred during the rebuild. + loop { + job.rebuild(); + + if let Ok(mut last) = last_rebuild.lock() { + *last = Some(Instant::now()); + } + + // Atomically check if more changes arrived during rebuild. + // If so, loop again to capture them. + if !thread_coord.check_pending() { + break; + } + looped = true; + } + + // Notify revalidator. If we looped, multiple prefixes may + // have changed so we send a full-sweep event. + if let Ok(guard) = notifier.lock() + && let Some(tx) = &*guard + { + let final_event = + if looped { event.to_full_sweep() } else { event }; + let _ = tx.send(final_event); + } + }) + { + error!(log, "failed to spawn {thread_name}: {e}"); + coordinator.finish(); + self.notify(event); + } + } + + /// Trigger a background rebuild of the IPv4 RPF cache. + /// + /// The RIB snapshot is taken lazily in the background thread, reducing + /// lock contention during RIB updates. + /// + /// The `changed_prefix` ([`Prefix4`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + /// + /// This trigger is rate limited based on configured interval. Only one + /// rebuild can be in progress at a time per address family. + pub fn trigger_rebuild_v4( + &self, + rib4_loc: Arc>, + changed_prefix: Option, + ) { + if !self.check_rate_limit() { + // Clear cache to force linear-scan fallback until next rebuild. + // This ensures lookups use fresh RIB data rather than stale cache. + if let Ok(mut guard) = self.cache_v4.write() { + *guard = None; + } + self.notify(RebuildEvent::V4(changed_prefix)); + return; + } + + if !self.rebuild_v4.try_start() { + self.rebuild_v4.signal_pending(); + return; + } + + let job = RebuildJob::V4 { + rib: rib4_loc, + cache: self.cache_v4.clone(), + }; + + self.spawn_rebuild( + job, + Arc::clone(&self.rebuild_v4), + RebuildEvent::V4(changed_prefix), + ); + } + + /// Trigger a background rebuild of the IPv6 RPF cache. + /// + /// The RIB snapshot is taken lazily in the background thread, reducing + /// lock contention during RIB updates. + /// + /// The `changed_prefix` ([`Prefix6`]) parameter enables targeted + /// revalidation: only (S,G) routes whose source falls within this prefix + /// need RPF re-checking. + /// + /// This trigger is rate limited based on configured interval. Only one + /// rebuild can be in progress at a time per address family. + pub fn trigger_rebuild_v6( + &self, + rib6_loc: Arc>, + changed_prefix: Option, + ) { + if !self.check_rate_limit() { + // Clear cache to force linear-scan fallback until next rebuild. + // This ensures lookups use fresh RIB data rather than stale cache. + if let Ok(mut guard) = self.cache_v6.write() { + *guard = None; + } + self.notify(RebuildEvent::V6(changed_prefix)); + return; + } + + if !self.rebuild_v6.try_start() { + self.rebuild_v6.signal_pending(); + return; + } + + let job = RebuildJob::V6 { + rib: rib6_loc, + cache: self.cache_v6.clone(), + }; + + self.spawn_rebuild( + job, + Arc::clone(&self.rebuild_v6), + RebuildEvent::V6(changed_prefix), + ); + } + + /// Look up the RPF neighbor for a multicast source address. + /// + /// Returns the best nexthop from the unicast RIB for reaching the source, + /// which is the valid RPF neighbor for (S,G) routes. Returns `None` if + /// no route exists for the source. + /// + /// Uses poptrie for O(1) lookup with linear scan fallback. + pub fn lookup( + &self, + source: IpAddr, + rib4_loc: &Arc>, + rib6_loc: &Arc>, + fanout: usize, + ) -> Option { + // Try poptrie lookup first + let cached_paths = match source { + IpAddr::V4(addr) => self.cache_v4.read().ok().and_then(|cache| { + cache.as_ref().and_then(|pt| pt.match_v4(u32::from(addr))) + }), + IpAddr::V6(addr) => self.cache_v6.read().ok().and_then(|cache| { + cache.as_ref().and_then(|pt| pt.match_v6(u128::from(addr))) + }), + }; + + if let Some(paths) = cached_paths { + return Self::get_rpf_neighbor(&paths, fanout); + } + + // Fallback to linear scan + match source { + IpAddr::V4(addr) => Self::lookup_v4(addr, rib4_loc, fanout), + IpAddr::V6(addr) => Self::lookup_v6(addr, rib6_loc, fanout), + } + } + + /// IPv4 RPF lookup (linear scan fallback when poptrie unavailable). + /// + /// This O(n) scan is acceptable for deployments where the + /// unicast RIB is small. + fn lookup_v4( + source: Ipv4Addr, + rib4_loc: &Arc>, + fanout: usize, + ) -> Option { + let rib = rib4_loc.lock().ok()?; + + // Find best matching prefix (longest-prefix match) + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + let source_bits = u32::from(source); + for (prefix, paths) in rib.iter() { + let prefix_bits = u32::from(prefix.value); + let mask = if prefix.length == 0 { + 0 + } else { + !0u32 << (32 - prefix.length) + }; + if (prefix_bits & mask) == (source_bits & mask) + && prefix.length > best_len + { + best_len = prefix.length; + best_paths = Some(paths); + } + } + + best_paths.and_then(|paths| Self::get_rpf_neighbor(paths, fanout)) + } + + /// IPv6 RPF lookup (linear scan fallback when poptrie unavailable). + /// + /// This O(n) scan is acceptable for deployments where the + /// unicast RIB is small. + fn lookup_v6( + source: Ipv6Addr, + rib6_loc: &Arc>, + fanout: usize, + ) -> Option { + let rib = rib6_loc.lock().ok()?; + + // Find best matching prefix (longest-prefix match) + let mut best_paths: Option<&BTreeSet> = None; + let mut best_len = 0u8; + + let source_bits = u128::from(source); + for (prefix, paths) in rib.iter() { + let prefix_bits = u128::from(prefix.value); + let mask = if prefix.length == 0 { + 0 + } else { + !0u128 << (128 - prefix.length) + }; + if (prefix_bits & mask) == (source_bits & mask) + && prefix.length > best_len + { + best_len = prefix.length; + best_paths = Some(paths); + } + } + + best_paths.and_then(|paths| Self::get_rpf_neighbor(paths, fanout)) + } + + /// Extract the RPF neighbor from a set of paths. + /// + /// For fanout == 1, returns the single bestpath nexthop. + /// For fanout > 1, returns the first active nexthop. All paths in loc-rib + /// are valid ECMP paths (already bestpath-selected), so any one suffices + /// for RPF verification. + fn get_rpf_neighbor( + paths: &BTreeSet, + fanout: usize, + ) -> Option { + let active_paths: BTreeSet = + paths.iter().filter(|p| !p.shutdown).cloned().collect(); + + if active_paths.is_empty() { + return None; + } + + if fanout == 1 { + bestpaths(&active_paths, 1) + .and_then(|selected| selected.iter().next().map(|p| p.nexthop)) + } else { + active_paths.iter().next().map(|p| p.nexthop) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::BTreeMap; + + use mg_common::log::*; + use mg_common::test::DEFAULT_INTERVAL_MS; + use mg_common::wait_for; + + use crate::test::TEST_WAIT_ITERATIONS; + use crate::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_RIB_PRIORITY_STATIC}; + + /// Helper to create empty Rib4 for tests + fn empty_rib4() -> Arc> { + Arc::new(Mutex::new(BTreeMap::new())) + } + + /// Helper to create empty Rib6 for tests + fn empty_rib6() -> Arc> { + Arc::new(Mutex::new(BTreeMap::new())) + } + + /// Extract nexthops from paths (filters out shutdown paths). + fn nexthops_from_paths(paths: &BTreeSet) -> BTreeSet { + paths + .iter() + .filter(|p| !p.shutdown) + .map(|p| p.nexthop) + .collect() + } + + #[test] + fn test_nexthops_from_paths() { + let mut paths = BTreeSet::new(); + let path1 = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + let path2 = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + paths.insert(path1); + paths.insert(path2); + + let next_hops = nexthops_from_paths(&paths); + assert_eq!(next_hops.len(), 2); + assert!(next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))); + assert!(next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)))); + assert!(!next_hops.contains(&IpAddr::V4(Ipv4Addr::new(192, 0, 2, 3)))); + } + + #[test] + fn test_rpf_table_linear_scan() { + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + paths.insert(path); + rib4_inner.insert(prefix, paths); + + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let log = init_file_logger("rpf_linear_scan.log"); + let rpf_table = RpfTable::new(log); + + // Without poptrie cache, should use linear scan + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let expected = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_table_with_poptrie() { + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + paths.insert(path); + rib4_inner.insert(prefix, paths.clone()); + + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + + let log = init_file_logger("rpf_poptrie.log"); + let rpf_table = RpfTable::new(log); + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + + // Wait for rebuild to complete + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Should now use poptrie cache + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let expected = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_table_shutdown_paths() { + // Test that shutdown paths are filtered out + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + // Active path + let active_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 10, + shutdown: false, + bgp: None, + vlan_id: None, + }; + // Shutdown path + let shutdown_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)), + rib_priority: 20, + shutdown: true, + bgp: None, + vlan_id: None, + }; + paths.insert(active_path); + paths.insert(shutdown_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_shutdown.log"); + let rpf_table = RpfTable::new(log); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let active_neighbor = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + + // Linear scan should return active path, not shutdown + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(active_neighbor) + ); + + // Rebuild poptrie and test again + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Poptrie should also return active path + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(active_neighbor) + ); + } + + #[test] + fn test_rpf_table_all_shutdown() { + // Test that a prefix with ALL paths shutdown returns None + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "192.0.2.0/24".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let shutdown_path = Path { + nexthop: IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), + rib_priority: 1, + shutdown: true, + bgp: None, + vlan_id: None, + }; + paths.insert(shutdown_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_all_shutdown.log"); + let rpf_table = RpfTable::new(log); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + + // Linear scan - should return `None` (all paths shutdown) + assert_eq!(rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), None); + + // Rebuild poptrie + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Poptrie finds the route but all paths shutdown, still `None` + assert_eq!(rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), None); + } + + #[test] + fn test_rpf_ecmp_different_priorities() { + // Test that bestpath selection prefers lower rib_priority + + let mut rib4_inner: Rib4 = BTreeMap::new(); + let prefix: Prefix4 = "192.0.2.0/24".parse().unwrap(); + + // Static route (priority 1) + let static_nexthop = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let static_path = Path { + nexthop: static_nexthop, + rib_priority: DEFAULT_RIB_PRIORITY_STATIC, + shutdown: false, + bgp: None, + vlan_id: None, + }; + + // BGP route (priority 20) + let bgp_nexthop = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)); + let bgp_path = Path { + nexthop: bgp_nexthop, + rib_priority: DEFAULT_RIB_PRIORITY_BGP, + shutdown: false, + bgp: None, + vlan_id: None, + }; + + let mut paths = BTreeSet::new(); + paths.insert(static_path); + paths.insert(bgp_path); + rib4_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_ecmp_priority.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = Arc::new(Mutex::new(rib4_inner)); + let rib6_loc = empty_rib6(); + let source = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + + // fanout=1: returns static (best priority) + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(static_nexthop) + ); + + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + // Same with poptrie + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(static_nexthop) + ); + } + + #[test] + fn test_rpf_table_linear_scan_v6() { + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Prefix6 = "2001:db8::/32".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + paths.insert(path); + rib6_inner.insert(prefix, paths); + + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + let log = init_file_logger("rpf_linear_scan_v6.log"); + let rpf_table = RpfTable::new(log); + + // Without poptrie cache, should use linear scan + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + let expected = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_table_with_poptrie_v6() { + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Prefix6 = "2001:db8::/32".parse().unwrap(); + + let mut paths = BTreeSet::new(); + let path = Path { + nexthop: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)), + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + paths.insert(path); + rib6_inner.insert(prefix, paths.clone()); + + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + + let log = init_file_logger("rpf_poptrie_v6.log"); + let rpf_table = RpfTable::new(log); + rpf_table.trigger_rebuild_v6(Arc::clone(&rib6_loc), None); + + // Wait for rebuild to complete + wait_for!( + rpf_table.cache_v6.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v6 rebuild timed out" + ); + + // Should now use poptrie cache + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + let expected = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + assert_eq!( + rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1), + Some(expected) + ); + } + + #[test] + fn test_rpf_lpm() { + // Test longest-prefix match -> the more specific route wins + let mut rib4_inner: Rib4 = BTreeMap::new(); + + // Less specific: 192.0.2.0/24 -> nexthop1 + let prefix_24: Prefix4 = "192.0.2.0/24".parse().unwrap(); + let nexthop1 = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + let mut paths1 = BTreeSet::new(); + paths1.insert(Path { + nexthop: nexthop1, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }); + rib4_inner.insert(prefix_24, paths1); + + // More specific: 192.0.2.128/25 -> nexthop2 + let prefix_25: Prefix4 = "192.0.2.128/25".parse().unwrap(); + let nexthop2 = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 2)); + let mut paths2 = BTreeSet::new(); + paths2.insert(Path { + nexthop: nexthop2, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }); + rib4_inner.insert(prefix_25, paths2); + + let log = init_file_logger("rpf_lpm.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = Arc::new(Mutex::new(rib4_inner.clone())); + let rib6_loc = empty_rib6(); + + // Source in /25 should match more specific route + let source_in_25 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 200)); + assert_eq!( + rpf_table.lookup(source_in_25, &rib4_loc, &rib6_loc, 1), + Some(nexthop2) + ); + + // Source in /24 but not /25 should match less specific route + let source_in_24 = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 50)); + assert_eq!( + rpf_table.lookup(source_in_24, &rib4_loc, &rib6_loc, 1), + Some(nexthop1) + ); + + // Test with poptrie too + rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" + ); + + assert_eq!( + rpf_table.lookup(source_in_25, &rib4_loc, &rib6_loc, 1), + Some(nexthop2) + ); + assert_eq!( + rpf_table.lookup(source_in_24, &rib4_loc, &rib6_loc, 1), + Some(nexthop1) + ); + } + + #[test] + fn test_rpf_ecmp_v6() { + // Test IPv6 ECMP: lookup returns one of the equal-priority paths + let mut rib6_inner: Rib6 = BTreeMap::new(); + let prefix: Prefix6 = "2001:db8::/32".parse().unwrap(); + + let nexthop1 = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)); + let nexthop2 = IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 2)); + + let path1 = Path { + nexthop: nexthop1, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + let path2 = Path { + nexthop: nexthop2, + rib_priority: 1, + shutdown: false, + bgp: None, + vlan_id: None, + }; + + let mut paths = BTreeSet::new(); + paths.insert(path1); + paths.insert(path2); + rib6_inner.insert(prefix, paths); + + let log = init_file_logger("rpf_ecmp_v6.log"); + let rpf_table = RpfTable::new(log); + let rib4_loc = empty_rib4(); + let rib6_loc = Arc::new(Mutex::new(rib6_inner)); + let source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 50)); + + // fanout=1: returns one of the equal-priority paths + let result = rpf_table.lookup(source, &rib4_loc, &rib6_loc, 1); + assert!( + result == Some(nexthop1) || result == Some(nexthop2), + "expected one of the ECMP nexthops" + ); + } +} diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 858693aa..bfe672a3 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -8,7 +8,18 @@ //! correctness and consistency of prefix operations (excluding wire format //! tests, which are in bgp/src/proptest.rs since they test BgpWireFormat). -use crate::types::{Prefix, Prefix4, Prefix6, StaticRouteKey}; +use crate::types::{ + DEFAULT_MULTICAST_VNI, MulticastAddr, MulticastAddrV4, MulticastAddrV6, + MulticastRoute, MulticastRouteKey, MulticastRouteKeyV4, + MulticastRouteKeyV6, MulticastRouteSource, Prefix, Prefix4, Prefix6, + StaticRouteKey, +}; +use omicron_common::address::{ + IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, IPV6_LINK_LOCAL_MULTICAST_SUBNET, + IPV6_MULTICAST_PREFIX, IPV6_SSM_SUBNET, +}; +use omicron_common::api::external::Vni; use proptest::prelude::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -322,3 +333,990 @@ proptest! { } } } + +// ============================================================================ +// Multicast address and route-key property tests and setup +// ============================================================================ + +// Strategy for generating IPv4 unicast addresses (non-multicast, non-loopback) +// Generates directly in valid ranges to avoid filter rejection limits +fn ipv4_unicast_strategy() -> impl Strategy { + prop_oneof![ + // 1.x.x.x - 126.x.x.x (skip 0.x.x.x and 127.x.x.x loopback) + (1u8..=126, any::(), any::(), any::()) + .prop_map(|(a, b, c, d)| Ipv4Addr::new(a, b, c, d)), + // 128.x.x.x - 223.x.x.x (before multicast range) + (128u8..=223, any::(), any::(), any::()) + .prop_map(|(a, b, c, d)| Ipv4Addr::new(a, b, c, d)), + ] +} + +// Strategy for generating IPv6 unicast addresses (non-multicast, non-loopback) +// Generates directly in valid ranges to avoid filter rejection limits +fn ipv6_unicast_strategy() -> impl Strategy { + // Generate any address except ff00::/8 (multicast) and ::1 (loopback) + // Multicast is only 1/256 of address space, so filter rejection is fine + any::().prop_filter_map("skip multicast/loopback", |bits| { + let addr = Ipv6Addr::from(bits); + if addr.is_multicast() || addr.is_loopback() { + None + } else { + Some(addr) + } + }) +} + +// Strategy for generating valid VNIs (0 to Vni::MAX_VNI) +fn valid_vni_strategy() -> impl Strategy { + 0u32..=Vni::MAX_VNI +} + +// Strategy for generating invalid VNIs (> Vni::MAX_VNI) +fn invalid_vni_strategy() -> impl Strategy { + (Vni::MAX_VNI + 1)..=u32::MAX +} + +// Strategy for admin-local scoped IPv6 multicast (ff04::/16) +fn admin_local_multicast_strategy() -> impl Strategy { + any::().prop_map(|bits| { + let addr = Ipv6Addr::from(bits); + let segments = addr.segments(); + Ipv6Addr::new( + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + ) + }) +} + +// Strategy for generating IPv6 multicast addresses that are not admin-local +// Admin-local scope is derived from IPV6_ADMIN_SCOPED_MULTICAST_PREFIX +// Scopes 0-2 are rejected by MulticastAddrV6::new (reserved, interface-local, +// link-local), so we use scope 3 or 5-15. +fn non_admin_local_multicast_strategy() -> impl Strategy +{ + // Extract admin-local scope from the constant (0xff04 -> 4) + let admin_local_scope = (IPV6_ADMIN_SCOPED_MULTICAST_PREFIX & 0xf) as u8; + // Flags: 4 bits (0-15), Scope: 3 or 5-15 (valid and not admin-local) + let scope = prop_oneof![Just(3u8), (admin_local_scope + 1)..=15]; + (any::(), scope, any::<[u16; 7]>()).prop_map(|(flags, scope, segs)| { + let first = IPV6_MULTICAST_PREFIX + | ((flags as u16 & 0xf) << 4) + | (scope as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("non-admin-local multicast is valid") + }) +} + +// Strategy for routable IPv6 unicast (not link-local, loopback, unspecified) +fn routable_ipv6_unicast_strategy() -> impl Strategy { + any::().prop_filter_map("must be routable unicast", |bits| { + let addr = Ipv6Addr::from(bits); + if !addr.is_multicast() + && !addr.is_loopback() + && !addr.is_unspecified() + && !addr.is_unicast_link_local() + { + Some(addr) + } else { + None + } + }) +} + +// ============================================================================ +// Arbitrary implementations for multicast types +// ============================================================================ +// +// These allow using `any::()` etc. in property tests, +// generating only valid instances of each type + +impl Arbitrary for MulticastAddrV4 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Derive range boundaries from constants + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let mcast_end = mcast_base + 15; // /4 prefix = 16 values + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + + // Generate directly in valid multicast ranges for efficiency + // Valid: 224.0.1.0 - 239.255.255.255 (excluding 224.0.0.x link-local) + prop_oneof![ + // mcast_base.0.1.0 - mcast_base.0.255.255 (skip link-local) + (1u8..=u8::MAX, any::()).prop_map(move |(c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, 0, c, d)) + .expect("mcast_base.0.1+ is valid multicast") + }), + // mcast_base.1.0.0 - mcast_base.255.255.255 + (1u8..=u8::MAX, any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, b, c, d)) + .expect("mcast_base.1+ is valid multicast") + } + ), + // (mcast_base+1).x.x.x - (ssm_first-1).x.x.x (globally routable) + ( + (mcast_base + 1)..=ssm_first - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("pre-SSM range is valid multicast") + }), + // ssm_first.x.x.x (SSM range) + (any::(), any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(ssm_first, b, c, d)) + .expect("SSM is valid") + } + ), + // (ssm_first+1).x.x.x - (mcast_end-1).x.x.x (GLOP, etc.) + ( + (ssm_first + 1)..=mcast_end - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("post-SSM range is valid multicast") + }), + // mcast_end.x.x.x (admin-scoped) + (any::(), any::(), any::()).prop_map( + move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_end, b, c, d)) + .expect("admin-scoped is valid") + } + ), + ] + .boxed() + } +} + +impl Arbitrary for MulticastAddrV6 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Generate with all valid flag/scope combinations + // Format: ff:: + // Valid scopes: 3-f (excluding 0=reserved, 1=if-local, 2=link-local) + // Flags: 0-f (all combinations valid) + (0x0u8..=0xf, 0x3u8..=0xf, any::<[u16; 7]>()) + .prop_map(|(flags, scope, segs)| { + let first_segment = IPV6_MULTICAST_PREFIX + | ((flags as u16) << 4) + | (scope as u16); + let addr = Ipv6Addr::new( + first_segment, + segs[0], + segs[1], + segs[2], + segs[3], + segs[4], + segs[5], + segs[6], + ); + MulticastAddrV6::new(addr) + .expect("scope 3-f with any flags is valid") + }) + .boxed() + } +} + +impl Arbitrary for MulticastAddr { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + prop_oneof![ + any::().prop_map(crate::types::MulticastAddr::V4), + any::().prop_map(crate::types::MulticastAddr::V6), + ] + .boxed() + } +} + +// Strategy for generating ASM (non-SSM) IPv4 multicast addresses directly +fn ipv4_asm_group_strategy() -> impl Strategy { + // Derive range boundaries from constants + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let mcast_end = mcast_base + 15; // /4 prefix = 16 values + let ssm_first = IPV4_SSM_SUBNET.addr().octets()[0]; + + // ASM ranges: mcast_base.0.1+ through (ssm_first-1), plus (ssm_first+1)-mcast_end + prop_oneof![ + // mcast_base.0.1.0 - mcast_base.0.255.255 (skip link-local) + (1u8..=u8::MAX, any::()).prop_map(move |(c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, 0, c, d)) + .expect("mcast_base.0.1+ is valid") + }), + // mcast_base.1.0.0 - mcast_base.255.255.255 + (1u8..=u8::MAX, any::(), any::()).prop_map(move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(mcast_base, b, c, d)) + .expect("mcast_base.1+ is valid") + }), + // (mcast_base+1).x.x.x - (ssm_first-1).x.x.x + ( + (mcast_base + 1)..=ssm_first - 1, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("pre-SSM ASM is valid") + }), + // (ssm_first+1).x.x.x - mcast_end.x.x.x (skip SSM) + ( + (ssm_first + 1)..=mcast_end, + any::(), + any::(), + any::() + ) + .prop_map(|(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("post-SSM ASM is valid") + }), + ] +} + +// Strategy for generating SSM IPv4 multicast addresses directly (232.x.x.x) +fn ipv4_ssm_group_strategy() -> impl Strategy { + let ssm_first_octet = IPV4_SSM_SUBNET.addr().octets()[0]; + (any::(), any::(), any::()).prop_map(move |(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(ssm_first_octet, b, c, d)) + .expect("SSM range is valid multicast") + }) +} + +// Strategy for generating ASM (non-SSM) IPv6 multicast addresses directly +fn ipv6_asm_group_strategy() -> impl Strategy { + // ASM: ff:: where flags != 3, scope in 3-f + let flags = + prop_oneof![Just(0x0u8), Just(0x1u8), Just(0x2u8), (0x4u8..=0xfu8),]; + (flags, 0x3u8..=0xf, any::<[u16; 7]>()).prop_map(|(f, s, segs)| { + let first = IPV6_MULTICAST_PREFIX | ((f as u16) << 4) | (s as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("ASM is valid") + }) +} + +// Strategy for generating SSM IPv6 multicast addresses directly (ff3x::) +fn ipv6_ssm_group_strategy() -> impl Strategy { + // SSM: ff3:: where scope in 3-f (link-local and above) + // IPV6_SSM_SUBNET is ff30::/12, so base segment is 0xff30 + let ssm_base = IPV6_SSM_SUBNET.addr().segments()[0]; + (0x3u8..=0xf, any::<[u16; 7]>()).prop_map(move |(scope, segs)| { + let first = ssm_base | (scope as u16); + MulticastAddrV6::new(Ipv6Addr::new( + first, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], + segs[6], + )) + .expect("SSM is valid") + }) +} + +impl Arbitrary for MulticastRouteKey { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Generate directly without filtering for efficiency with high case counts + let vni = 0u32..=Vni::MAX_VNI; + + prop_oneof![ + // V4 ASM (*,G) + (ipv4_asm_group_strategy(), vni.clone()).prop_map(|(grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: None, + group: grp, + vni, + }) + }), + // V4 ASM (S,G) + ( + ipv4_unicast_strategy(), + ipv4_asm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + }) + }), + // V4 SSM (S,G) - SSM requires source + ( + ipv4_unicast_strategy(), + ipv4_ssm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + }) + }), + // V6 ASM (*,G) + (ipv6_asm_group_strategy(), vni.clone()).prop_map(|(grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: None, + group: grp, + vni, + }) + }), + // V6 ASM (S,G) + ( + ipv6_unicast_strategy(), + ipv6_asm_group_strategy(), + vni.clone() + ) + .prop_map(|(src, grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + }) + }), + // V6 SSM (S,G) - SSM requires source + (ipv6_unicast_strategy(), ipv6_ssm_group_strategy(), vni).prop_map( + |(src, grp, vni)| { + MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + }) + } + ), + ] + .boxed() + } +} + +proptest! { + /// Property: Arbitrary `MulticastAddrV4` always validates + #[test] + fn prop_multicast_addr_v4_arbitrary_valid(addr in any::()) { + // Arbitrary impl only generates valid addresses + prop_assert!(addr.ip().is_multicast()); + } + + /// Property: Arbitrary `MulticastAddrV6` always validates + #[test] + fn prop_multicast_addr_v6_arbitrary_valid(addr in any::()) { + // Arbitrary impl only generates valid addresses + prop_assert!(addr.ip().is_multicast()); + } + + /// Property: IPv4 unicast addresses are rejected as multicast + #[test] + fn prop_multicast_addr_v4_rejects_unicast(addr in ipv4_unicast_strategy()) { + let result = MulticastAddrV4::new(addr); + prop_assert!( + result.is_err(), + "unicast {addr} should be rejected as multicast" + ); + } + + /// Property: IPv6 unicast addresses are rejected as multicast + #[test] + fn prop_multicast_addr_v6_rejects_unicast(addr in ipv6_unicast_strategy()) { + let result = MulticastAddrV6::new(addr); + prop_assert!( + result.is_err(), + "unicast {addr} should be rejected as multicast" + ); + } + + /// Property: IPv4 link-local multicast (224.0.0.x) is rejected + #[test] + fn prop_multicast_addr_v4_rejects_link_local(last_octet in 0u8..=u8::MAX) { + let mcast_base = IPV4_MULTICAST_RANGE.addr().octets()[0]; + let addr = Ipv4Addr::new(mcast_base, 0, 0, last_octet); + let result = MulticastAddrV4::new(addr); + prop_assert!( + result.is_err(), + "link-local {addr} should be rejected" + ); + } + + /// Property: IPv6 link-local multicast (ff02::/16) is rejected + #[test] + fn prop_multicast_addr_v6_rejects_link_local(segs in any::<[u16; 7]>()) { + let prefix = IPV6_LINK_LOCAL_MULTICAST_SUBNET.addr().segments()[0]; + let link_local = Ipv6Addr::new( + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let result = MulticastAddrV6::new(link_local); + prop_assert!( + result.is_err(), + "link-local {link_local} should be rejected" + ); + } + + /// Property: IPv6 interface-local multicast (ff01::/16) is rejected + #[test] + fn prop_multicast_addr_v6_rejects_interface_local(segs in any::<[u16; 7]>()) { + let prefix = IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET.addr().segments()[0]; + let if_local = Ipv6Addr::new( + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let result = MulticastAddrV6::new(if_local); + prop_assert!( + result.is_err(), + "interface-local {if_local} should be rejected" + ); + } + + /// Property: `MulticastAddrV4` roundtrip through ip() preserves address + #[test] + fn prop_multicast_addr_ip_roundtrip_v4(mcast in any::()) { + let ip = mcast.ip(); + let roundtrip = MulticastAddrV4::new(ip).expect("valid"); + prop_assert_eq!(mcast, roundtrip); + } + + /// Property: `MulticastAddrV6` roundtrip through ip() preserves address + #[test] + fn prop_multicast_addr_ip_roundtrip_v6(mcast in any::()) { + let ip = mcast.ip(); + let roundtrip = MulticastAddrV6::new(ip).expect("valid"); + prop_assert_eq!(mcast, roundtrip); + } + + /// Property: Arbitrary `MulticastRouteKey` always validates + #[test] + fn prop_route_key_arbitrary_valid(key in any::()) { + prop_assert!( + key.validate().is_ok(), + "arbitrary key should validate: {key:?}" + ); + } + + /// Property: (*,G) with ASM group validates (source optional for ASM) + #[test] + fn prop_route_key_asm_star_g_valid_v4(group in ipv4_asm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_ok(), + "(*,G) with ASM {group} should be valid"); + } + + /// Property: (*,G) with ASM group validates (source optional for ASM) + #[test] + fn prop_route_key_asm_star_g_valid_v6(group in ipv6_asm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_ok(), + "(*,G) with ASM {group} should be valid" + ); + } + + /// Property: (S,G) with unicast source validates (covers ASM and SSM) + #[test] + fn prop_route_key_sg_valid_v4( + src in ipv4_unicast_strategy(), + group in any::(), + ) { + let key = MulticastRouteKey::source_specific_v4(src, group); + prop_assert!( + key.validate().is_ok(), + "(S,G) with unicast source {src} should be valid" + ); + } + + /// Property: (S,G) with unicast source validates (covers ASM and SSM) + #[test] + fn prop_route_key_sg_valid_v6( + src in ipv6_unicast_strategy(), + group in any::(), + ) { + let key = MulticastRouteKey::source_specific_v6(src, group); + prop_assert!( + key.validate().is_ok(), + "(S,G) with unicast source {src} should be valid" + ); + } + + /// Property: SSM without source fails validation (IPv4) + #[test] + fn prop_route_key_ssm_requires_source_v4(group in ipv4_ssm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {group} should require source" + ); + } + + /// Property: SSM without source fails validation (IPv6) + #[test] + fn prop_route_key_ssm_requires_source_v6(group in ipv6_ssm_group_strategy()) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {group} should require source" + ); + } + + /// Property: SSM with source passes validation (IPv4) + #[test] + fn prop_route_key_ssm_with_source_valid_v4( + src in ipv4_unicast_strategy(), + group in ipv4_ssm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v4(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{group} should be valid" + ); + } + + /// Property: SSM with source passes validation (IPv6) + #[test] + fn prop_route_key_ssm_with_source_valid_v6( + src in ipv6_unicast_strategy(), + group in ipv6_ssm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v6(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{group} should be valid" + ); + } + + /// Property: VNI in valid range passes validation + #[test] + fn prop_route_key_valid_vni( + src in ipv4_unicast_strategy(), + group in any::(), + vni in valid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V4(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + let result = key.validate(); + prop_assert!( + result.is_ok(), + "VNI {vni} should be valid: {result:?}" + ); + } + + /// Property: VNI exceeding 24 bits fails validation + #[test] + fn prop_route_key_invalid_vni( + src in ipv4_unicast_strategy(), + group in any::(), + vni in invalid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V4(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + prop_assert!( + key.validate().is_err(), + "VNI {vni} should be invalid (> 2^24-1)" + ); + } + + /// Property: VNI in valid range passes validation (IPv6) + #[test] + fn prop_route_key_valid_vni_v6( + src in ipv6_unicast_strategy(), + group in any::(), + vni in valid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + let result = key.validate(); + prop_assert!( + result.is_ok(), + "VNI {vni} should be valid for v6: {result:?}" + ); + } + + /// Property: VNI exceeding 24 bits fails validation (IPv6) + #[test] + fn prop_route_key_invalid_vni_v6( + src in ipv6_unicast_strategy(), + group in any::(), + vni in invalid_vni_strategy(), + ) { + // Use (S,G) so both ASM and SSM groups work + let key = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + vni, + ) + .expect("valid key construction"); + prop_assert!( + key.validate().is_err(), + "VNI {vni} should be invalid (> 2^24-1)" + ); + } + + /// Property: Broadcast source address is rejected (IPv4) + #[test] + fn prop_route_key_broadcast_source_rejected( + group in ipv4_asm_group_strategy(), + ) { + let key = MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(Ipv4Addr::BROADCAST), + group, + vni: DEFAULT_MULTICAST_VNI, + }); + prop_assert!( + key.validate().is_err(), + "broadcast source should be rejected" + ); + } + + /// Property: AF mismatch (v4 source, v6 group) rejected at construction + #[test] + fn prop_route_key_af_mismatch_v4_v6( + src in ipv4_unicast_strategy(), + group in any::(), + ) { + let result = MulticastRouteKey::new( + Some(IpAddr::V4(src)), + group.into(), + DEFAULT_MULTICAST_VNI, + ); + prop_assert!( + result.is_err(), + "v4 source with v6 group should be rejected" + ); + } + + /// Property: AF mismatch (v6 source, v4 group) rejected at construction + #[test] + fn prop_route_key_af_mismatch_v6_v4( + src in ipv6_unicast_strategy(), + group in any::(), + ) { + let result = MulticastRouteKey::new( + Some(IpAddr::V6(src)), + group.into(), + DEFAULT_MULTICAST_VNI, + ); + prop_assert!( + result.is_err(), + "v6 source with v4 group should be rejected" + ); + } + + /// Property: Multicast address as source rejected + #[test] + fn prop_route_key_multicast_source_rejected_v4( + src in any::(), + group in ipv4_asm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v4(src.ip(), group); + prop_assert!( + key.validate().is_err(), + "multicast source {src} should be rejected" + ); + } + + /// Property: Multicast address as source rejected + #[test] + fn prop_route_key_multicast_source_rejected_v6( + src in any::(), + group in ipv6_asm_group_strategy(), + ) { + let key = MulticastRouteKey::source_specific_v6(src.ip(), group); + prop_assert!( + key.validate().is_err(), + "multicast source {src} should be rejected" + ); + } + + /// Property: Route with admin-local underlay group passes validation + #[test] + fn prop_route_admin_local_underlay_valid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + prop_assert!( + route.validate().is_ok(), + "route with admin-local underlay should be valid" + ); + } + + /// Property: Route with non-admin-local underlay fails validation + #[test] + fn prop_route_non_admin_local_underlay_invalid( + group in ipv4_asm_group_strategy(), + underlay in non_admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let route = MulticastRoute::new( + key, + underlay.ip(), + MulticastRouteSource::Static, + ); + prop_assert!( + route.validate().is_err(), + "route with non-admin-local underlay {underlay} should fail" + ); + } + + /// Property: Unicast RPF neighbor passes validation (v4 group, v4 rpf) + #[test] + fn prop_route_unicast_rpf_valid_v4( + group in ipv4_asm_group_strategy(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf)); + prop_assert!( + route.validate().is_ok(), + "unicast v4 RPF {rpf} should be valid" + ); + } + + /// Property: Unicast RPF neighbor passes validation (v6 group, v6 rpf) + #[test] + fn prop_route_unicast_rpf_valid_v6( + group in ipv6_asm_group_strategy(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf)); + prop_assert!( + route.validate().is_ok(), + "unicast v6 RPF {rpf} should be valid" + ); + } + + /// Property: Multicast RPF neighbor fails validation (IPv4) + #[test] + fn prop_route_multicast_rpf_invalid_v4( + group in ipv4_asm_group_strategy(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf.ip())); + prop_assert!( + route.validate().is_err(), + "multicast RPF {rpf} should be rejected" + ); + } + + /// Property: Multicast RPF neighbor fails validation (IPv6) + #[test] + fn prop_route_multicast_rpf_invalid_v6( + group in ipv6_asm_group_strategy(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf.ip())); + prop_assert!( + route.validate().is_err(), + "multicast RPF {rpf} should be rejected" + ); + } + + /// Property: RPF AF mismatch fails validation (v4 rpf, v6 group) + #[test] + fn prop_route_rpf_af_mismatch_v4_v6( + group in ipv6_asm_group_strategy(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(rpf)); + prop_assert!( + route.validate().is_err(), + "v4 RPF with v6 group should be rejected" + ); + } + + /// Property: RPF AF mismatch fails validation (v6 rpf, v4 group) + #[test] + fn prop_route_rpf_af_mismatch_v6_v4( + group in ipv4_asm_group_strategy(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V6(rpf)); + prop_assert!( + route.validate().is_err(), + "v6 RPF with v4 group should be rejected" + ); + } + + /// Property: Routable unicast underlay nexthops pass validation + #[test] + fn prop_route_routable_nexthop_valid( + group in ipv4_asm_group_strategy(), + nexthop in routable_ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(nexthop); + prop_assert!( + route.validate().is_ok(), + "routable nexthop {nexthop} should be valid" + ); + } + + /// Property: Multicast underlay nexthop fails validation + #[test] + fn prop_route_multicast_nexthop_invalid( + group in ipv4_asm_group_strategy(), + nexthop in any::(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(nexthop.ip()); + prop_assert!( + route.validate().is_err(), + "multicast nexthop {nexthop} should be rejected" + ); + } + + /// Property: Link-local underlay nexthop fails validation + #[test] + fn prop_route_link_local_nexthop_invalid( + group in ipv4_asm_group_strategy(), + segs in any::<[u16; 7]>(), + underlay in admin_local_multicast_strategy(), + ) { + // Create a link-local address (fe80::/10) + let link_local = Ipv6Addr::new( + 0xfe80, + segs[0] & 0x03ff, // Keep only bottom 10 bits for /10 + segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + ); + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(link_local); + prop_assert!( + route.validate().is_err(), + "link-local nexthop {link_local} should be rejected" + ); + } + + /// Property: Loopback underlay nexthop fails validation + #[test] + fn prop_route_loopback_nexthop_invalid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(Ipv6Addr::LOCALHOST); + prop_assert!( + route.validate().is_err(), + "loopback nexthop should be rejected" + ); + } + + /// Property: Unspecified underlay nexthop fails validation + #[test] + fn prop_route_unspecified_nexthop_invalid( + group in ipv4_asm_group_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + let key = MulticastRouteKey::any_source(group.into()); + let mut route = MulticastRoute::new( + key, + underlay, + MulticastRouteSource::Static, + ); + route.underlay_nexthops.insert(Ipv6Addr::UNSPECIFIED); + prop_assert!( + route.validate().is_err(), + "unspecified nexthop should be rejected" + ); + } +} diff --git a/rdb/src/test.rs b/rdb/src/test.rs index 8e4b27b3..8bcf5de0 100644 --- a/rdb/src/test.rs +++ b/rdb/src/test.rs @@ -9,6 +9,9 @@ use slog::Logger; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicU64, Ordering}; +/// Default iteration count for wait_for! macro (5 seconds at 10ms polling). +pub const TEST_WAIT_ITERATIONS: u64 = 500; + /// A test database wrapper that automatically cleans up the database directory /// when dropped, but only if the test succeeded. /// diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 8fc19b9d..59891dc9 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -5,6 +5,13 @@ use crate::error::Error; use anyhow::Result; use chrono::{DateTime, Utc}; +use omicron_common::address::{ + IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, IPV4_SSM_SUBNET, + IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET, + IPV6_LINK_LOCAL_MULTICAST_SUBNET, IPV6_MULTICAST_RANGE, + IPV6_RESERVED_SCOPE_MULTICAST_SUBNET, IPV6_SSM_SUBNET, +}; +use omicron_common::api::external::Vni; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -255,6 +262,55 @@ impl PrefixDbKey for Prefix6 { } } +/// Extension trait to add `contains` method for checking if a prefix contains +/// an IP address. The base [`Prefix`] type is defined in rdb-types, but this +/// method is specific to RDB's RPF (Reverse Path Forwarding) needs for +/// multicast routing. +pub trait PrefixContains { + /// Check if this prefix contains the given IP address. + /// + /// Performs LPM matching to determine if the address falls + /// within this prefix. Returns `Some(prefix_length)` if the address is + /// contained, `None` otherwise. + fn contains(&self, addr: IpAddr) -> Option; +} + +impl PrefixContains for Prefix { + fn contains(&self, addr: IpAddr) -> Option { + match (self, addr) { + (Prefix::V4(p), IpAddr::V4(a)) => { + let prefix_bits = u32::from(p.value); + let addr_bits = u32::from(a); + let mask = if p.length == 0 { + 0 + } else { + !0u32 << (32 - p.length) + }; + if (prefix_bits & mask) == (addr_bits & mask) { + Some(p.length) + } else { + None + } + } + (Prefix::V6(p), IpAddr::V6(a)) => { + let prefix_bits = u128::from(p.value); + let addr_bits = u128::from(a); + let mask = if p.length == 0 { + 0 + } else { + !0u128 << (128 - p.length) + }; + if (prefix_bits & mask) == (addr_bits & mask) { + Some(p.length) + } else { + None + } + } + _ => None, // IPv4 prefix with IPv6 address or vice versa + } + } +} + #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] pub enum Asn { TwoOctet(u16), @@ -420,3 +476,774 @@ impl Display for PrefixChangeNotification { write!(f, "PrefixChangeNotification [ {pcn}]") } } + +// ============================================================================ +// MRIB (Multicast RIB) Types +// ============================================================================ + +/// Default VNI for fleet-wide multicast routing. +pub const DEFAULT_MULTICAST_VNI: u32 = Vni::DEFAULT_MULTICAST_VNI.as_u32(); + +/// A validated IPv4 multicast address. +/// +/// This type guarantees that the inner address is a routable multicast address +/// (not link-local). +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv4Addr", into = "Ipv4Addr")] +#[schemars(transparent)] +pub struct MulticastAddrV4(Ipv4Addr); + +impl MulticastAddrV4 { + /// Create a new validated IPv4 multicast address. + pub fn new(value: Ipv4Addr) -> Result { + // Must be in multicast range (224.0.0.0/4) + if !IPV4_MULTICAST_RANGE.contains(value) { + return Err(Error::Validation(format!( + "IPv4 address {value} is not multicast \ + (must be in {IPV4_MULTICAST_RANGE})" + ))); + } + + // Reject link-local multicast (224.0.0.0/24) + if IPV4_LINK_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv4 address {value} is link-local multicast \ + ({IPV4_LINK_LOCAL_MULTICAST_SUBNET}) which is not routable" + ))); + } + + Ok(Self(value)) + } + + /// Returns the underlying IPv4 address. + #[inline] + pub const fn ip(&self) -> Ipv4Addr { + self.0 + } +} + +impl fmt::Display for MulticastAddrV4 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for MulticastAddrV4 { + type Error = Error; + + fn try_from(value: Ipv4Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv4Addr { + fn from(addr: MulticastAddrV4) -> Self { + addr.0 + } +} + +/// A validated IPv6 multicast address. +/// +/// This type guarantees that the inner address is a routable multicast address +/// (not interface-local, link-local, or reserved scope). +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(try_from = "Ipv6Addr", into = "Ipv6Addr")] +#[schemars(transparent)] +pub struct MulticastAddrV6(Ipv6Addr); + +impl MulticastAddrV6 { + /// Create a new validated IPv6 multicast address. + pub fn new(value: Ipv6Addr) -> Result { + // Must be in multicast range (ff00::/8) + if !IPV6_MULTICAST_RANGE.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is not multicast \ + (must be in {IPV6_MULTICAST_RANGE})" + ))); + } + + // Reject reserved scope (ff00::/16) (reserved, not usable) + if IPV6_RESERVED_SCOPE_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is in reserved scope \ + ({IPV6_RESERVED_SCOPE_MULTICAST_SUBNET}) which is not routable" + ))); + } + + // Reject interface-local multicast (ff01::/16) + if IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is interface-local multicast \ + ({IPV6_INTERFACE_LOCAL_MULTICAST_SUBNET}) which is not routable" + ))); + } + + // Reject link-local multicast (ff02::/16) + if IPV6_LINK_LOCAL_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv6 address {value} is link-local multicast \ + ({IPV6_LINK_LOCAL_MULTICAST_SUBNET}) which is not routable" + ))); + } + + Ok(Self(value)) + } + + /// Returns the underlying IPv6 address. + #[inline] + pub const fn ip(&self) -> Ipv6Addr { + self.0 + } +} + +impl fmt::Display for MulticastAddrV6 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for MulticastAddrV6 { + type Error = Error; + + fn try_from(value: Ipv6Addr) -> Result { + Self::new(value) + } +} + +impl From for Ipv6Addr { + fn from(addr: MulticastAddrV6) -> Self { + addr.0 + } +} + +/// A validated multicast group address (IPv4 or IPv6). +/// +/// This type guarantees that the contained address is a routable multicast +/// address. Construction is only possible through validated paths. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum MulticastAddr { + V4(MulticastAddrV4), + V6(MulticastAddrV6), +} + +impl MulticastAddr { + /// Create an IPv4 multicast address from octets. + pub fn new_v4(a: u8, b: u8, c: u8, d: u8) -> Result { + Ok(Self::V4(MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d))?)) + } + + /// Create an IPv6 multicast address from segments. + pub fn new_v6(segments: [u16; 8]) -> Result { + Ok(Self::V6(MulticastAddrV6::new(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + ))?)) + } + + /// Returns the underlying IP address. + pub fn ip(&self) -> IpAddr { + match self { + Self::V4(v4) => IpAddr::V4(v4.ip()), + Self::V6(v6) => IpAddr::V6(v6.ip()), + } + } +} + +impl fmt::Display for MulticastAddr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + MulticastAddr::V4(addr) => write!(f, "{}", addr), + MulticastAddr::V6(addr) => write!(f, "{}", addr), + } + } +} + +impl From for MulticastAddr { + fn from(addr: MulticastAddrV4) -> Self { + Self::V4(addr) + } +} + +impl From for MulticastAddr { + fn from(addr: MulticastAddrV6) -> Self { + Self::V6(addr) + } +} + +impl TryFrom for MulticastAddr { + type Error = Error; + + fn try_from(value: Ipv4Addr) -> Result { + Ok(Self::V4(MulticastAddrV4::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = Error; + + fn try_from(value: Ipv6Addr) -> Result { + Ok(Self::V6(MulticastAddrV6::new(value)?)) + } +} + +impl TryFrom for MulticastAddr { + type Error = Error; + + fn try_from(value: IpAddr) -> Result { + match value { + IpAddr::V4(v4) => Self::try_from(v4), + IpAddr::V6(v6) => Self::try_from(v6), + } + } +} + +/// IPv4 multicast route key with type-enforced address family matching. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct MulticastRouteKeyV4 { + /// Source address (`None` for (*,G) routes). + pub(crate) source: Option, + /// Multicast group address. + pub(crate) group: MulticastAddrV4, + /// VNI (Virtual Network Identifier). + #[serde(default = "default_multicast_vni")] + pub(crate) vni: u32, +} + +/// IPv6 multicast route key with type-enforced address family matching. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct MulticastRouteKeyV6 { + /// Source address (`None` for (*,G) routes). + pub(crate) source: Option, + /// Multicast group address. + pub(crate) group: MulticastAddrV6, + /// VNI (Virtual Network Identifier). + #[serde(default = "default_multicast_vni")] + pub(crate) vni: u32, +} + +/// Multicast route key: (Source, Group) pair for source-specific multicast, +/// or (*, Group) for any-source multicast. +/// +/// Uses type-enforced address family matching: IPv4 sources can only be +/// paired with IPv4 groups, and IPv6 sources with IPv6 groups. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum MulticastRouteKey { + V4(MulticastRouteKeyV4), + V6(MulticastRouteKeyV6), +} + +const fn default_multicast_vni() -> u32 { + DEFAULT_MULTICAST_VNI +} + +impl fmt::Display for MulticastRouteKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::V4(key) => match key.source { + Some(src) => write!(f, "({src},{})", key.group), + None => write!(f, "(*,{})", key.group), + }, + Self::V6(key) => match key.source { + Some(src) => write!(f, "({src},{})", key.group), + None => write!(f, "(*,{})", key.group), + }, + } + } +} + +impl MulticastRouteKey { + /// Create a multicast route key, validating address family matching. + /// + /// Use this when the address family is not known at compile time (e.g., + /// from API requests). Returns an error if source and group address + /// families don't match. For compile-time type safety, prefer + /// [`Self::source_specific_v4`]/[`Self::source_specific_v6`] or + /// [`Self::any_source`]. + pub fn new( + source: Option, + group: MulticastAddr, + vni: u32, + ) -> Result { + match group { + MulticastAddr::V4(g) => { + let src = match source { + None => None, + Some(IpAddr::V4(s)) => Some(s), + Some(IpAddr::V6(s)) => { + return Err(Error::Validation(format!( + "source {s} is IPv6 but group {g} is IPv4" + ))); + } + }; + Ok(Self::V4(MulticastRouteKeyV4 { + source: src, + group: g, + vni, + })) + } + MulticastAddr::V6(g) => { + let src = match source { + None => None, + Some(IpAddr::V6(s)) => Some(s), + Some(IpAddr::V4(s)) => { + return Err(Error::Validation(format!( + "source {s} is IPv4 but group {g} is IPv6" + ))); + } + }; + Ok(Self::V6(MulticastRouteKeyV6 { + source: src, + group: g, + vni, + })) + } + } + } + + /// Create an any-source multicast route (*,G) with default VNI. + pub fn any_source(group: MulticastAddr) -> Self { + match group { + MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { + source: None, + group: g, + vni: DEFAULT_MULTICAST_VNI, + }), + MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { + source: None, + group: g, + vni: DEFAULT_MULTICAST_VNI, + }), + } + } + + /// Create a source-specific IPv4 multicast route (S,G) with default VNI. + pub fn source_specific_v4( + source: Ipv4Addr, + group: MulticastAddrV4, + ) -> Self { + Self::V4(MulticastRouteKeyV4 { + source: Some(source), + group, + vni: DEFAULT_MULTICAST_VNI, + }) + } + + /// Create a source-specific IPv6 multicast route (S,G) with default VNI. + pub fn source_specific_v6( + source: Ipv6Addr, + group: MulticastAddrV6, + ) -> Self { + Self::V6(MulticastRouteKeyV6 { + source: Some(source), + group, + vni: DEFAULT_MULTICAST_VNI, + }) + } + + /// Create an any-source multicast route (*,G) with specified VNI. + pub fn any_source_with_vni(group: MulticastAddr, vni: u32) -> Self { + match group { + MulticastAddr::V4(g) => Self::V4(MulticastRouteKeyV4 { + source: None, + group: g, + vni, + }), + MulticastAddr::V6(g) => Self::V6(MulticastRouteKeyV6 { + source: None, + group: g, + vni, + }), + } + } + + /// Create a source-specific IPv4 multicast route (S,G) with VNI. + pub fn source_specific_v4_with_vni( + source: Ipv4Addr, + group: MulticastAddrV4, + vni: u32, + ) -> Self { + Self::V4(MulticastRouteKeyV4 { + source: Some(source), + group, + vni, + }) + } + + /// Create a source-specific IPv6 multicast route (S,G) with VNI. + pub fn source_specific_v6_with_vni( + source: Ipv6Addr, + group: MulticastAddrV6, + vni: u32, + ) -> Self { + Self::V6(MulticastRouteKeyV6 { + source: Some(source), + group, + vni, + }) + } + + /// Get the source address as IpAddr. + pub fn source(&self) -> Option { + match self { + Self::V4(k) => k.source.map(IpAddr::V4), + Self::V6(k) => k.source.map(IpAddr::V6), + } + } + + /// Get the group address. + pub fn group(&self) -> MulticastAddr { + match self { + Self::V4(k) => MulticastAddr::V4(k.group), + Self::V6(k) => MulticastAddr::V6(k.group), + } + } + + /// Get the VNI. + pub fn vni(&self) -> u32 { + match self { + Self::V4(k) => k.vni, + Self::V6(k) => k.vni, + } + } + + /// Serialize this key to bytes for use as a sled database key. + pub fn db_key(&self) -> Result, Error> { + let s = serde_json::to_string(self).map_err(|e| { + Error::Parsing(format!( + "failed to serialize multicast route key: {e}" + )) + })?; + Ok(s.as_bytes().into()) + } + + /// Deserialize a key from sled database bytes. + pub fn from_db_key(v: &[u8]) -> Result { + let s = String::from_utf8_lossy(v); + serde_json::from_str(&s).map_err(|e| { + Error::DbKey(format!("failed to parse multicast route key: {e}")) + }) + } + + /// Validate the multicast route key. + /// + /// Checks: + /// - SSM groups require a source address (RFC 4607) + /// - IPv4: 232.0.0.0/8 + /// - IPv6: ff30::/12 (superset covering all ff3x:: scopes for validation) + /// - Source address (if present) must be unicast + /// - VNI must be in valid range (0 to 16777215) + pub fn validate(&self) -> Result<(), Error> { + // VNI must fit in 24 bits + const MAX_VNI: u32 = (1 << 24) - 1; + if self.vni() > MAX_VNI { + return Err(Error::Validation(format!( + "VNI {} exceeds maximum value {MAX_VNI}", + self.vni() + ))); + } + + // SSM addresses require a source (RFC 4607). Note: ASM addresses + // can also have sources, allowing (S,G) joins on ASM ranges gives + // customers source filtering outside the SSM range. + let is_ssm = match self { + Self::V4(k) => IPV4_SSM_SUBNET.contains(k.group.ip()), + Self::V6(k) => IPV6_SSM_SUBNET.contains(k.group.ip()), + }; + if is_ssm && self.source().is_none() { + return Err(Error::Validation(format!( + "SSM group {} requires a source address", + self.group() + ))); + } + + // Validate source address if present + match self { + Self::V4(k) => { + if let Some(addr) = k.source { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not multicast" + ))); + } + if addr.is_broadcast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not broadcast" + ))); + } + if addr.is_loopback() { + return Err(Error::Validation(format!( + "source address {addr} must not be loopback" + ))); + } + } + } + Self::V6(k) => { + if let Some(addr) = k.source { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not multicast" + ))); + } + if addr.is_loopback() { + return Err(Error::Validation(format!( + "source address {addr} must not be loopback" + ))); + } + } + } + } + + Ok(()) + } +} + +/// Multicast route entry containing replication groups and metadata. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MulticastRoute { + /// The multicast route key (S,G) or (*,G). + pub key: MulticastRouteKey, + /// Expected RPF neighbor for the source (for RPF checks). + pub rpf_neighbor: Option, + /// Underlay unicast nexthops for multicast replication. + /// + /// Unicast IPv6 addresses where encapsulated overlay multicast traffic + /// is forwarded. These are sled underlay addresses hosting VMs subscribed + /// to the multicast group. Forms the outgoing interface list (OIL). + pub underlay_nexthops: BTreeSet, + /// Underlay multicast group address (ff04::X). + /// + /// Admin-local scoped IPv6 multicast address corresponding to the overlay + /// multicast group. 1:1 mapped and always derived from the overlay + /// multicast group in Omicron. + pub underlay_group: Ipv6Addr, + /// Route source (static, IGMP, etc.). + pub source: MulticastRouteSource, + /// Creation timestamp. + pub created: DateTime, + /// Last updated timestamp. + /// + /// Only updated when route fields change semantically (rpf_neighbor, + /// underlay_group, underlay_nexthops, source). An idempotent upsert with + /// an identical value does not update this timestamp. + pub updated: DateTime, +} + +impl MulticastRoute { + pub fn new( + key: MulticastRouteKey, + underlay_group: Ipv6Addr, + source: MulticastRouteSource, + ) -> Self { + let now = Utc::now(); + Self { + key, + rpf_neighbor: None, + underlay_nexthops: BTreeSet::new(), + underlay_group, + source, + created: now, + updated: now, + } + } + + pub fn add_target(&mut self, target: Ipv6Addr) { + self.underlay_nexthops.insert(target); + self.updated = Utc::now(); + } + + pub fn remove_target(&mut self, target: &Ipv6Addr) -> bool { + let removed = self.underlay_nexthops.remove(target); + if removed { + self.updated = Utc::now(); + } + removed + } + + /// Validate the multicast route. + /// + /// Checks: + /// - Key validation (source unicast, AF match, VNI range) + /// - Underlay group must be admin-local scoped IPv6 multicast (ff04::/16) + /// - RPF neighbor (if present) must be unicast + /// - RPF neighbor address family must match group address family + /// - Underlay nexthops must be routable unicast IPv6 (not link-local) + pub fn validate(&self) -> Result<(), Error> { + self.key.validate()?; + + // Validate underlay_group is admin-local scoped IPv6 multicast + // (ff04::/16). Overlay groups are mapped 1:1 to admin-local underlay + // groups. + if self.underlay_group.segments()[0] + != IPV6_ADMIN_SCOPED_MULTICAST_PREFIX + { + return Err(Error::Validation(format!( + "underlay_group {} must be admin-local multicast (ff04::X)", + self.underlay_group + ))); + } + + // Validate RPF neighbor if present + if let Some(rpf) = &self.rpf_neighbor { + match rpf { + IpAddr::V4(addr) => { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + if addr.is_broadcast() { + return Err(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not broadcast" + ))); + } + // Address family must match group + if !matches!(self.key.group(), MulticastAddr::V4(_)) { + return Err(Error::Validation(format!( + "RPF neighbor {addr} is IPv4 but group {} is IPv6", + self.key.group() + ))); + } + } + IpAddr::V6(addr) => { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "RPF neighbor {addr} must be unicast, not multicast" + ))); + } + // AF must match group + if !matches!(self.key.group(), MulticastAddr::V6(_)) { + return Err(Error::Validation(format!( + "RPF neighbor {addr} is IPv6 but group {} is IPv4", + self.key.group() + ))); + } + } + } + } + + // Validate underlay nexthops are routable unicast IPv6 + for target in &self.underlay_nexthops { + if target.is_multicast() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must be unicast, not multicast" + ))); + } + if target.is_unspecified() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be unspecified (::)" + ))); + } + if target.is_loopback() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be loopback (::1)" + ))); + } + if target.is_unicast_link_local() { + return Err(Error::Validation(format!( + "underlay nexthop {target} must not be link-local (fe80::/10)" + ))); + } + } + + Ok(()) + } +} + +/// Source of a multicast route entry. +#[derive( + Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq, +)] +pub enum MulticastRouteSource { + /// Static route configured via API. + Static, + /// Learned via IGMP snooping (future). + Igmp, + /// Learned via MLD snooping (future). + Mld, +} + +/// Notification for MRIB changes, sent to watchers. +#[derive(Clone, Default, Debug)] +pub struct MribChangeNotification { + pub changed: BTreeSet, +} + +impl From for MribChangeNotification { + fn from(value: MulticastRouteKey) -> Self { + Self { + changed: BTreeSet::from([value]), + } + } +}