From 327a6090b227d4fc4d3e0f7a34701ffdd7577132 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Thu, 4 Dec 2025 19:07:03 +0000 Subject: [PATCH 1/7] MRIB: Multicast RIB implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Multicast Routing Information Base for multicast support. The MRIB follows a two-table architecture (mrib_in → mrib_loc) with RPF verification against the unicast RIB when sources are provided. This PR includes: - `rdb/src/db.rs`: modifications to accomodate MRIB implementation and persistence - `rdb/src/mrib/mod.rs`: the core MRIB implementation with route storage and change notifications - `rdb/src/mrib/rpf.rs`: RPF verification using poptrie for O(1) LPM lookups, with rate-limited rebuilds triggered on unicast RIB changes - `rdb/src/types.rs`: Validated multicast address types with input validation - `mg-api/src/lib.rs`: API v3 (VERSION_MULTICAST_SUPPORT) with new endpoints - `mgd/src/mrib_admin.rs`: HTTP handlers bridging API to MRIB - `mgadm/src/mrib.rs`: CLI for MRIB inspection and configuration Note that Omicron is source of truth multicast overlay/underlay groups/addresses. --- Cargo.lock | 315 +- Cargo.toml | 9 +- mg-api/src/lib.rs | 172 +- mgadm/src/main.rs | 6 + mgadm/src/mrib.rs | 440 ++ mgd/src/admin.rs | 66 +- mgd/src/error.rs | 10 +- mgd/src/main.rs | 1 + mgd/src/mrib_admin.rs | 221 + openapi/mg-admin/mg-admin-3.0.0-ababb5.json | 4515 +++++++++++++++++++ openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/Cargo.toml | 2 + rdb/src/db.rs | 840 +++- rdb/src/error.rs | 9 + rdb/src/lib.rs | 2 + rdb/src/mrib/mod.rs | 696 +++ rdb/src/mrib/rpf.rs | 1145 +++++ rdb/src/test.rs | 55 + rdb/src/types.rs | 999 ++++ 19 files changed, 9372 insertions(+), 133 deletions(-) create mode 100644 mgadm/src/mrib.rs create mode 100644 mgd/src/mrib_admin.rs create mode 100644 openapi/mg-admin/mg-admin-3.0.0-ababb5.json create mode 100644 rdb/src/mrib/mod.rs create mode 100644 rdb/src/mrib/rpf.rs diff --git a/Cargo.lock b/Cargo.lock index 3c8d8e94..43af7df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "api_identity" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "omicron-workspace-hack", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -355,7 +366,7 @@ dependencies = [ [[package]] name = "bhyve_api" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=827e6615bfebfd94d41504dcd1517a0f22e3166a#827e6615bfebfd94d41504dcd1517a0f22e3166a" +source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" dependencies = [ "bhyve_api_sys", "libc", @@ -365,7 +376,7 @@ dependencies = [ [[package]] name = "bhyve_api_sys" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=827e6615bfebfd94d41504dcd1517a0f22e3166a#827e6615bfebfd94d41504dcd1517a0f22e3166a" +source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" dependencies = [ "libc", "strum 0.26.3", @@ -676,7 +687,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clickhouse-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "atomicwrites", @@ -687,7 +698,7 @@ dependencies = [ "derive_more", "expectorate", "itertools 0.14.0", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -732,11 +743,11 @@ dependencies = [ [[package]] name = "cockroach-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "chrono", "csv", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -765,7 +776,7 @@ source = "git+https://github.com/oxidecomputer/dendrite?branch=main#738c80d18d5e dependencies = [ "anyhow", "chrono", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", "oxnet", "rand 0.9.2", "schemars", @@ -964,7 +975,7 @@ dependencies = [ [[package]] name = "crucible-smf" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/crucible?rev=65ca41e821ef53ec9c28909357f23e3348169e4f#65ca41e821ef53ec9c28909357f23e3348169e4f" +source = "git+https://github.com/oxidecomputer/crucible?rev=7103cd3a3d7b0112d2949dd135db06fef0c156bb#7103cd3a3d7b0112d2949dd135db06fef0c156bb" dependencies = [ "crucible-workspace-hack", "libc", @@ -1143,10 +1154,10 @@ dependencies = [ "ispf", "libnet", "mg-common", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1263ced8177893d46da54a914e4c510dc2bfc8)", "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1263ced8177893d46da54a914e4c510dc2bfc8)", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oximeter-producer", "oxnet", "pretty_assertions", @@ -1607,10 +1618,10 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "ereport-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "dropshot", - "omicron-uuid-kinds", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -1887,7 +1898,7 @@ dependencies = [ [[package]] name = "gateway-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "base64 0.22.1", "chrono", @@ -1895,7 +1906,7 @@ dependencies = [ "ereport-types", "gateway-messages", "gateway-types", - "omicron-uuid-kinds", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "progenitor 0.10.0", "rand 0.9.2", @@ -1912,7 +1923,7 @@ dependencies = [ [[package]] name = "gateway-messages" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/management-gateway-service?rev=6bd2660d651332f38949cdfd3d23d751ca54b120#6bd2660d651332f38949cdfd3d23d751ca54b120" +source = "git+https://github.com/oxidecomputer/management-gateway-service?rev=ea2f39ccdea124b5affcad0ca17bc5dacf65823a#ea2f39ccdea124b5affcad0ca17bc5dacf65823a" dependencies = [ "bitflags 2.9.4", "hubpack", @@ -1929,14 +1940,14 @@ dependencies = [ [[package]] name = "gateway-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "daft", "dropshot", "gateway-messages", "hex", - "omicron-common", - "omicron-uuid-kinds", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -2528,9 +2539,9 @@ dependencies = [ [[package]] name = "iddqd" -version = "0.3.14" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac5efd33e0c5eb0ac45cbd210541a214dac576896ca97ba08e16e3b1079cdd8" +checksum = "6b215e67ed1d1a4b1702acd787c487d16e4c977c5dcbcc4587bdb5ea26b6ce06" dependencies = [ "allocator-api2", "daft", @@ -2590,7 +2601,7 @@ dependencies = [ [[package]] name = "illumos-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "async-trait", @@ -2608,8 +2619,8 @@ dependencies = [ "itertools 0.14.0", "libc", "macaddr", - "omicron-common", - "omicron-uuid-kinds", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80)", "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80)", @@ -2709,14 +2720,14 @@ dependencies = [ [[package]] name = "internal-dns-resolver" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "futures", "hickory-proto 0.25.2", "hickory-resolver 0.25.2", "internal-dns-types", - "omicron-common", - "omicron-uuid-kinds", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "qorb", "reqwest", @@ -2727,15 +2738,16 @@ dependencies = [ [[package]] name = "internal-dns-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "chrono", - "omicron-common", - "omicron-uuid-kinds", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", + "strum 0.27.2", ] [[package]] @@ -3290,7 +3302,7 @@ dependencies = [ "clap", "libc", "libnet", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oximeter-producer", "oxnet", "schemars", @@ -3396,9 +3408,9 @@ dependencies = [ "mg-api", "mg-common", "mg-lower", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oxide-tokio-rt", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oximeter-producer", "proptest", "rand 0.8.5", @@ -3490,9 +3502,9 @@ dependencies = [ [[package]] name = "newtype-uuid" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d1216f62e63be5fb25a9ecd1e2b37b1556a9b8c02f4831770f5d01df85c226" +checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" dependencies = [ "schemars", "serde", @@ -3526,16 +3538,14 @@ dependencies = [ [[package]] name = "nexus-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "chrono", "futures", "iddqd", - "nexus-sled-agent-shared", "nexus-types", - "omicron-common", - "omicron-passwords", - "omicron-uuid-kinds", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "oxnet", "progenitor 0.10.0", @@ -3551,18 +3561,17 @@ dependencies = [ [[package]] name = "nexus-sled-agent-shared" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "camino", "chrono", "daft", - "id-map", "iddqd", "illumos-utils", "indent_write", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-passwords", - "omicron-uuid-kinds", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -3577,10 +3586,10 @@ dependencies = [ [[package]] name = "nexus-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", - "api_identity", + "api_identity 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "async-trait", "base64 0.22.1", "chrono", @@ -3592,12 +3601,12 @@ dependencies = [ "derive-where", "derive_more", "dropshot", + "ereport-types", "futures", "gateway-client", "gateway-types", "http", "humantime", - "id-map", "iddqd", "illumos-utils", "indent_write", @@ -3607,9 +3616,9 @@ dependencies = [ "newtype-uuid", "newtype_derive", "nexus-sled-agent-shared", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-passwords", - "omicron-uuid-kinds", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "openssl", "oximeter-db", @@ -3622,6 +3631,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "sled-hardware-types", "slog", "slog-error-chain", "steno", @@ -3843,7 +3853,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" dependencies = [ "anyhow", - "api_identity", + "api_identity 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", "async-trait", "backoff", "camino", @@ -3858,7 +3868,51 @@ dependencies = [ "ipnetwork", "macaddr", "mg-admin-client 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=08f2a34d487658e87545ffbba3add632a82baf0d)", - "omicron-uuid-kinds", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "omicron-workspace-hack", + "oxnet", + "parse-display", + "progenitor-client 0.10.0", + "protocol", + "rand 0.9.2", + "regress", + "reqwest", + "schemars", + "semver 1.0.27", + "serde", + "serde_human_bytes", + "serde_json", + "serde_with", + "slog", + "slog-error-chain", + "strum 0.27.2", + "thiserror 2.0.16", + "tokio", + "tufaceous-artifact", + "uuid", +] + +[[package]] +name = "omicron-common" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "anyhow", + "api_identity 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "async-trait", + "backoff", + "camino", + "chrono", + "daft", + "dropshot", + "futures", + "hex", + "http", + "iddqd", + "ipnetwork", + "macaddr", + "mg-admin-client 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=08f2a34d487658e87545ffbba3add632a82baf0d)", + "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "oxnet", "parse-display", @@ -3885,7 +3939,7 @@ dependencies = [ [[package]] name = "omicron-passwords" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "argon2", "omicron-workspace-hack", @@ -3909,6 +3963,18 @@ dependencies = [ "schemars", ] +[[package]] +name = "omicron-uuid-kinds" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "daft", + "newtype-uuid", + "newtype-uuid-macros", + "paste", + "schemars", +] + [[package]] name = "omicron-workspace-hack" version = "0.1.0" @@ -4157,10 +4223,29 @@ dependencies = [ "chrono", "clap", "omicron-workspace-hack", - "oximeter-macro-impl", - "oximeter-schema", - "oximeter-timeseries-macro", - "oximeter-types", + "oximeter-macro-impl 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "oximeter-schema 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "oximeter-timeseries-macro 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "prettyplease", + "syn 2.0.106", + "toml 0.8.23", + "uuid", +] + +[[package]] +name = "oximeter" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "anyhow", + "chrono", + "clap", + "omicron-workspace-hack", + "oximeter-macro-impl 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "oximeter-schema 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "oximeter-timeseries-macro 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "prettyplease", "syn 2.0.106", "toml 0.8.23", @@ -4170,7 +4255,7 @@ dependencies = [ [[package]] name = "oximeter-db" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "async-recursion", @@ -4193,10 +4278,10 @@ dependencies = [ "libc", "nom", "num", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "oxide-tokio-rt", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oxql-types", "parse-display", "qorb", @@ -4231,19 +4316,30 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "oximeter-macro-impl" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "omicron-workspace-hack", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "oximeter-producer" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "chrono", "dropshot", "internal-dns-resolver", "internal-dns-types", "nexus-client", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", - "oximeter", + "oximeter 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "schemars", "serde", "slog", @@ -4263,7 +4359,28 @@ dependencies = [ "clap", "heck 0.5.0", "omicron-workspace-hack", - "oximeter-types", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "prettyplease", + "proc-macro2", + "quote", + "schemars", + "serde", + "slog-error-chain", + "syn 2.0.106", + "toml 0.8.23", +] + +[[package]] +name = "oximeter-schema" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "anyhow", + "chrono", + "clap", + "heck 0.5.0", + "omicron-workspace-hack", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "prettyplease", "proc-macro2", "quote", @@ -4280,8 +4397,21 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" dependencies = [ "omicron-workspace-hack", - "oximeter-schema", - "oximeter-types", + "oximeter-schema 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "oximeter-timeseries-macro" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "omicron-workspace-hack", + "oximeter-schema 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "proc-macro2", "quote", "syn 2.0.106", @@ -4296,7 +4426,27 @@ dependencies = [ "chrono", "float-ord", "num", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", + "omicron-workspace-hack", + "parse-display", + "regex", + "schemars", + "serde", + "strum 0.27.2", + "thiserror 2.0.16", + "uuid", +] + +[[package]] +name = "oximeter-types" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +dependencies = [ + "bytes", + "chrono", + "float-ord", + "num", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "parse-display", "regex", @@ -4310,7 +4460,7 @@ dependencies = [ [[package]] name = "oxlog" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "camino", @@ -4326,9 +4476,9 @@ dependencies = [ [[package]] name = "oxnet" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8200429754152e6379fbb1dd06eea90156c3b67591f6e31d08e787d010ef0168" +checksum = "5dc6fb07ecd6d2a17ff1431bc5b3ce11036c0b6dd93a3c4904db5b910817b162" dependencies = [ "ipnetwork", "schemars", @@ -4339,14 +4489,14 @@ dependencies = [ [[package]] name = "oxql-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "chrono", "highway", "num", "omicron-workspace-hack", - "oximeter-types", + "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "schemars", "serde", "serde_json", @@ -4611,6 +4761,11 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "poptrie" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/poptrie?branch=multipath#ca52bef3f87ff1a67d81b3c6e601dcb5fdbcc165" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -5156,7 +5311,9 @@ dependencies = [ "clap", "itertools 0.14.0", "mg-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oxnet", + "poptrie", "proptest", "rdb-types", "schemars", @@ -5647,9 +5804,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80ece43fc6fbed4eb5392ab50c07334d3e577cbf40997ee896fe7af40bba4245" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -5666,18 +5823,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a576275b607a2c86ea29e410193df32bc680303c82f31e275bbfcafe8b33be5" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.227" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e694923b8824cf0e9b382adf0f60d4e05f348f357b38833a3fa5ed7c2ede04" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -5950,10 +6107,10 @@ dependencies = [ [[package]] name = "sled-hardware-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "illumos-utils", - "omicron-common", + "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", "schemars", "serde", @@ -7275,7 +7432,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "update-engine" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#b36f03290d085a065e5339b5e6bde3a0f99dc56d" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" dependencies = [ "anyhow", "cancel-safe-futures", diff --git a/Cargo.toml b/Cargo.toml index ef2285a9..dc14e906 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"} -oxnet = { version = "0.1.3", default-features = false, features = ["schemars", "serde"] } -omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} +oximeter = { git = "https://github.com/oxidecomputer/omicron", branch = "zl/mcast-implicit-lifecycle" } +oximeter-producer = { git = "https://github.com/oxidecomputer/omicron", branch = "zl/mcast-implicit-lifecycle" } +oxnet = { version = "0.1.4", default-features = false, features = ["schemars", "serde"] } +omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "zl/mcast-implicit-lifecycle" } +poptrie = { git = "https://github.com/oxidecomputer/poptrie", branch = "multipath" } 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 513755ac..7355cb03 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), + (3, MULTICAST_SUPPORT), (2, IPV6_BASIC), (1, INITIAL), ]); @@ -371,6 +372,84 @@ pub trait MgAdminApi { async fn static_list_v6_routes( 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 all imported multicast routes (`mrib_in`). + #[endpoint { method = GET, path = "/mrib/status/imported", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_status_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Get installed multicast routes (`mrib_loc`, RPF-validated). + #[endpoint { method = GET, path = "/mrib/status/installed", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_status_installed( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError>; + + /// Get a specific multicast route by key. + #[endpoint { method = GET, path = "/mrib/route", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_get_route( + rqctx: RequestContext, + query: Query, + ) -> Result, HttpError>; + + /// Get a specific installed multicast route by key (`mrib_loc`). + /// + /// Same query parameters as `mrib_get_route`, but returns the RPF-selected + /// route from the installed table. Useful for verifying RPF neighbor and + /// dataplane eligibility. + #[endpoint { method = GET, path = "/mrib/route/installed", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_get_selected_route( + 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 = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_static_add( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// Delete 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 = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_static_delete( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + /// List all static multicast routes. + #[endpoint { method = GET, path = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn mrib_static_list( + 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 mrib_get_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 mrib_set_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; } #[derive(Debug, Copy, Clone, Serialize, Deserialize, JsonSchema)] @@ -562,6 +641,97 @@ 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 listing MRIB routes. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribStatusQuery { + /// Filter by address family (`None` returns all). + #[serde(default)] + pub address_family: Option, + /// Filter by route origin, i.e. "Static" or "Dynamic" + /// (`None` returns all). + #[serde(default)] + pub route_origin: Option, +} + +/// Query parameters for looking up a specific MRIB route. +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct MribRouteQuery { + /// Source address (`None` for ASM (*,G)). + pub source: Option, + /// Multicast group address. + pub group: IpAddr, + /// VNI (defaults to 77 for fleet-scoped multicast). + #[serde(default = "default_multicast_vni")] + pub vni: u32, +} + +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/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..8642be78 --- /dev/null +++ b/mgadm/src/mrib.rs @@ -0,0 +1,440 @@ +// 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, ValueEnum}; + +use mg_admin_client::Client; +use mg_admin_client::types::{ + MribRpfRebuildIntervalRequest, MulticastAddr, MulticastRoute, + RouteOriginFilter, +}; +use rdb::types::AddressFamily; + +/// Filter for route origin. +#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] +pub enum RouteOrigin { + /// Static routes only (manually configured). + Static, + /// Dynamic routes only (learned via IGMP, MLD, etc.). + Dynamic, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// View MRIB state. + Status(StatusCommand), + + /// Get a specific multicast route by key. + Get(GetCommand), + + /// Get a specific installed multicast route (mrib_loc). + GetInstalled(GetCommand), + + /// RPF rebuild configuration. + Rpf(RpfCommand), + + /// Static multicast route management. + Static(StaticCommand), +} + +#[derive(Debug, Args)] +pub struct StatusCommand { + #[command(subcommand)] + command: StatusCmd, +} + +#[derive(Subcommand, Debug)] +pub enum StatusCmd { + /// Get all imported multicast routes (`mrib_in`). + Imported { + /// Filter by address family. + #[arg(short, long, value_enum)] + af: Option, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_enum)] + origin: Option, + }, + + /// Get installed multicast routes (`mrib_loc`, RPF-validated). + Installed { + /// Filter by address family. + #[arg(short, long, value_enum)] + af: Option, + + /// Filter by route origin ("static" or "dynamic"). + #[arg(long, value_enum)] + origin: Option, + }, +} + +#[derive(Debug, Args)] +pub struct GetCommand { + /// Multicast group address. + #[arg(short, long)] + group: IpAddr, + + /// Source address (omit for any-source (*,G)). + #[arg(short, long)] + source: Option, + + /// VNI (defaults to 77 for fleet-scoped multicast). + #[arg(short, long, default_value_t = 77)] + vni: u32, +} + +#[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, + }, +} + +#[derive(Debug, Args)] +pub struct StaticCommand { + #[command(subcommand)] + command: StaticRouteCmd, +} + +#[derive(Subcommand, Debug)] +pub enum StaticRouteCmd { + /// List all static multicast routes. + List, +} + +pub async fn commands(command: Commands, c: Client) -> Result<()> { + match command { + Commands::Status(status_cmd) => match status_cmd.command { + StatusCmd::Imported { af, origin } => { + get_imported(c, af, origin).await? + } + StatusCmd::Installed { af, origin } => { + get_installed(c, af, origin).await? + } + }, + Commands::Get(get_cmd) => { + get_route(c, get_cmd.group, get_cmd.source, get_cmd.vni).await? + } + Commands::GetInstalled(get_cmd) => { + get_route_installed(c, get_cmd.group, get_cmd.source, get_cmd.vni) + .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? + } + }, + Commands::Static(static_cmd) => match static_cmd.command { + StaticRouteCmd::List => static_list(c).await?, + }, + } + Ok(()) +} + +async fn get_imported( + c: Client, + af: Option, + origin: Option, +) -> Result<()> { + let origin_filter = origin.map(|o| match o { + RouteOrigin::Static => RouteOriginFilter::Static, + RouteOrigin::Dynamic => RouteOriginFilter::Dynamic, + }); + let routes = c + .mrib_status_imported(af.as_ref(), origin_filter) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_installed( + c: Client, + af: Option, + origin: Option, +) -> Result<()> { + let origin_filter = origin.map(|o| match o { + RouteOrigin::Static => RouteOriginFilter::Static, + RouteOrigin::Dynamic => RouteOriginFilter::Dynamic, + }); + let routes = c + .mrib_status_installed(af.as_ref(), origin_filter) + .await? + .into_inner(); + print_routes(&routes); + Ok(()) +} + +async fn get_route( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let route = c + .mrib_get_route(&group, source.as_ref(), Some(vni)) + .await? + .into_inner(); + println!("{route:#?}"); + Ok(()) +} + +async fn get_route_installed( + c: Client, + group: IpAddr, + source: Option, + vni: u32, +) -> Result<()> { + let route = c + .mrib_get_selected_route(&group, source.as_ref(), Some(vni)) + .await? + .into_inner(); + println!("{route:#?}"); + Ok(()) +} + +async fn get_rpf_interval(c: Client) -> Result<()> { + let result = c.mrib_get_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.mrib_set_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { + interval_ms, + }) + .await?; + println!("Updated RPF rebuild interval to: {interval_ms}ms"); + Ok(()) +} + +async fn static_list(c: Client) -> Result<()> { + let routes = c.mrib_static_list().await?.into_inner(); + if routes.is_empty() { + println!("No static multicast routes"); + } else { + print_routes(&routes); + } + Ok(()) +} + +fn print_routes(routes: &[MulticastRoute]) { + if routes.is_empty() { + println!("No multicast routes"); + return; + } + for route in routes { + let key = &route.key; + let source_str = match &key.source { + Some(s) => s.to_string(), + None => "*".to_string(), + }; + let group_str = match &key.group { + MulticastAddr::V4(v4) => v4.to_string(), + MulticastAddr::V6(v6) => v6.to_string(), + }; + println!( + "({source_str},{group_str}) vni={} underlay={} rpf={:?} nexthops={} source={:?}", + key.vni, + 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_get_command_group_only() { + let cli = TestCli::try_parse_from(["test", "get", "-g", "225.1.2.3"]) + .unwrap(); + + match cli.command { + Commands::Get(cmd) => { + assert_eq!(cmd.group, IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))); + assert_eq!(cmd.source, None); + assert_eq!(cmd.vni, 77); // default + } + _ => panic!("expected Get command"), + } + } + + #[test] + fn test_get_command_all_flags() { + let cli = TestCli::try_parse_from([ + "test", + "get", + "-g", + "225.1.2.3", + "-s", + "10.0.0.1", + "-v", + "100", + ]) + .unwrap(); + + match cli.command { + Commands::Get(cmd) => { + assert_eq!(cmd.group, IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))); + assert_eq!( + cmd.source, + Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))) + ); + assert_eq!(cmd.vni, 100); + } + _ => panic!("expected Get command"), + } + } + + #[test] + fn test_get_command_ipv6() { + let cli = TestCli::try_parse_from([ + "test", + "get", + "--group", + "ff0e::1", + "--source", + "2001:db8::1", + "--vni", + "42", + ]) + .unwrap(); + + match cli.command { + Commands::Get(cmd) => { + assert_eq!( + cmd.group, + IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) + ); + assert_eq!( + cmd.source, + Some(IpAddr::V6(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 + ))) + ); + assert_eq!(cmd.vni, 42); + } + _ => panic!("expected Get command"), + } + } + + #[test] + fn test_status_imported_with_af() { + let cli = TestCli::try_parse_from([ + "test", "status", "imported", "-a", "ipv4", + ]) + .unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Imported { af, origin } => { + assert_eq!(af, Some(AddressFamily::Ipv4)); + assert_eq!(origin, None); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_imported_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 { af, origin } => { + assert_eq!(af, None); + assert_eq!(origin, Some(RouteOrigin::Dynamic)); + } + _ => panic!("expected Imported"), + }, + _ => panic!("expected Status command"), + } + } + + #[test] + fn test_status_installed_no_af() { + let cli = + TestCli::try_parse_from(["test", "status", "installed"]).unwrap(); + + match cli.command { + Commands::Status(cmd) => match cmd.command { + StatusCmd::Installed { af, origin } => { + assert_eq!(af, None); + assert_eq!(origin, None); + } + _ => panic!("expected Installed"), + }, + _ => 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"), + } + } + + #[test] + fn test_static_list() { + let cli = TestCli::try_parse_from(["test", "static", "list"]).unwrap(); + + match cli.command { + Commands::Static(cmd) => { + assert!(matches!(cmd.command, StaticRouteCmd::List)); + } + _ => panic!("expected Static command"), + } + } +} diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 2526e89e..74fe5f7a 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; @@ -438,6 +438,70 @@ impl MgAdminApi for MgAdminApiImpl { ) -> Result, HttpError> { static_admin::static_list_v6_routes(ctx).await } + + async fn mrib_status_imported( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::mrib_status_imported(rqctx, query).await + } + + async fn mrib_status_installed( + rqctx: RequestContext, + query: Query, + ) -> Result>, HttpError> + { + mrib_admin::mrib_status_installed(rqctx, query).await + } + + async fn mrib_get_route( + rqctx: RequestContext, + query: Query, + ) -> Result, HttpError> { + mrib_admin::mrib_get_route(rqctx, query).await + } + + async fn mrib_get_selected_route( + rqctx: RequestContext, + query: Query, + ) -> Result, HttpError> { + mrib_admin::mrib_get_selected_route(rqctx, query).await + } + + async fn mrib_static_add( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::mrib_static_add(rqctx, request).await + } + + async fn mrib_static_delete( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::mrib_static_delete(rqctx, request).await + } + + async fn mrib_static_list( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + mrib_admin::mrib_static_list(rqctx).await + } + + async fn mrib_get_rpf_rebuild_interval( + rqctx: RequestContext, + ) -> Result, HttpError> { + mrib_admin::mrib_get_rpf_rebuild_interval(rqctx).await + } + + async fn mrib_set_rpf_rebuild_interval( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + mrib_admin::mrib_set_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 9e105007..dabac44c 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..c5c4d952 --- /dev/null +++ b/mgd/src/mrib_admin.rs @@ -0,0 +1,221 @@ +// 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::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use dropshot::{ + HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, RequestContext, TypedBody, +}; + +use mg_api::{ + MribAddStaticRequest, MribDeleteStaticRequest, + MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, + MribStatusQuery, 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 mrib_status_imported( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + 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 mrib_status_installed( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result>, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + 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 mrib_get_route( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // Build the key from query params + let group = match q.group { + IpAddr::V4(v4) => MulticastAddr::try_from(v4).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?, + IpAddr::V6(v6) => MulticastAddr::try_from(v6).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?, + }; + + let key = MulticastRouteKey { + source: q.source, + group, + vni: q.vni, + }; + + // Look up in `mrib_in` (all imported routes) + let route = ctx.db.get_mcast_route(&key).ok_or_else(|| { + HttpError::for_not_found(None, format!("route {key} not found")) + })?; + + Ok(HttpResponseOk(route)) +} + +pub async fn mrib_get_selected_route( + rqctx: RequestContext>, + query: dropshot::Query, +) -> Result, HttpError> { + let ctx = rqctx.context(); + let q = query.into_inner(); + + // Build the key from query params + let group = match q.group { + IpAddr::V4(v4) => MulticastAddr::try_from(v4).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?, + IpAddr::V6(v6) => MulticastAddr::try_from(v6).map_err(|e| { + HttpError::for_bad_request( + None, + format!("invalid group address: {e}"), + ) + })?, + }; + + let key = MulticastRouteKey { + source: q.source, + group, + vni: q.vni, + }; + + // Look up in `mrib_loc` (installed/selected routes) + 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"), + ) + })?; + + Ok(HttpResponseOk(route)) +} + +pub async fn mrib_static_add( + 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 mrib_static_delete( + 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 mrib_static_list( + 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 mrib_get_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 mrib_set_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/openapi/mg-admin/mg-admin-3.0.0-ababb5.json b/openapi/mg-admin/mg-admin-3.0.0-ababb5.json new file mode 100644 index 00000000..0d40b08e --- /dev/null +++ b/openapi/mg-admin/mg-admin-3.0.0-ababb5.json @@ -0,0 +1,4515 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Maghemite Admin", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "3.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": "mrib_get_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": "mrib_set_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/route": { + "get": { + "summary": "Get a specific multicast route by key.", + "operationId": "mrib_get_route", + "parameters": [ + { + "in": "query", + "name": "group", + "description": "Multicast group address.", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for ASM (*,G)).", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to 77 for fleet-scoped multicast).", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/route/installed": { + "get": { + "summary": "Get a specific installed multicast route by key (`mrib_loc`).", + "description": "Same query parameters as `mrib_get_route`, but returns the RPF-selected route from the installed table. Useful for verifying RPF neighbor and dataplane eligibility.", + "operationId": "mrib_get_selected_route", + "parameters": [ + { + "in": "query", + "name": "group", + "description": "Multicast group address.", + "required": true, + "schema": { + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "source", + "description": "Source address (`None` for ASM (*,G)).", + "schema": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + { + "in": "query", + "name": "vni", + "description": "VNI (defaults to 77 for fleet-scoped multicast).", + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MulticastRoute" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/mrib/static/route": { + "get": { + "summary": "List all static multicast routes.", + "operationId": "mrib_static_list", + "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": "mrib_static_add", + "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": "Delete static multicast routes.", + "description": "This endpoint is intended for Nexus RPW use. Operators should configure multicast group membership through the Oxide API.", + "operationId": "mrib_static_delete", + "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" + } + } + } + }, + "/mrib/status/imported": { + "get": { + "summary": "Get all imported multicast routes (`mrib_in`).", + "operationId": "mrib_status_imported", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (`None` returns all).", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin, i.e. \"Static\" or \"Dynamic\" (`None` returns all).", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + } + ], + "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/installed": { + "get": { + "summary": "Get installed multicast routes (`mrib_loc`, RPF-validated).", + "operationId": "mrib_status_installed", + "parameters": [ + { + "in": "query", + "name": "address_family", + "description": "Filter by address family (`None` returns all).", + "schema": { + "$ref": "#/components/schemas/AddressFamily" + } + }, + { + "in": "query", + "name": "route_origin", + "description": "Filter by route origin, i.e. \"Static\" or \"Dynamic\" (`None` returns all).", + "schema": { + "$ref": "#/components/schemas/RouteOriginFilter" + } + } + ], + "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/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" + } + } + } + } + }, + "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" + ] + }, + "MulticastAddr": { + "description": "A validated multicast group address (IPv4 or IPv6).\n\nThis type guarantees that the contained address is a routable multicast address. Construction is only possible through validated paths.", + "oneOf": [ + { + "type": "object", + "properties": { + "V4": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "V4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "V6": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "V6" + ], + "additionalProperties": false + } + ] + }, + "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-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.", + "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.", + "type": "object", + "properties": { + "group": { + "description": "Multicast group address.", + "allOf": [ + { + "$ref": "#/components/schemas/MulticastAddr" + } + ] + }, + "source": { + "nullable": true, + "description": "Source address (None for any-source multicast (*,G)).", + "type": "string", + "format": "ip" + }, + "vni": { + "description": "VNI (Virtual Network Identifier) - defaults to 77 for fleet-wide multicast, but allows future per-VPC multicast routing.", + "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-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" + ] + }, + "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 b4b0dc8d..06320092 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-2.0.0-b4b255.json \ No newline at end of file +mg-admin-3.0.0-ababb5.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 30110339..a02b3c62 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; +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, @@ -106,6 +165,7 @@ pub struct Db { log: Logger, } + unsafe impl Sync for Db {} unsafe impl Send for Db {} @@ -115,22 +175,68 @@ 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), 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) { @@ -141,6 +247,53 @@ impl Db { *lock!(self.reaper.stale_max) = stale_max; } + // ------------------------------------------------------------------------ + // 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 }); @@ -581,20 +734,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()); @@ -610,6 +755,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( @@ -618,20 +768,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()); @@ -647,6 +789,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 @@ -1219,10 +1366,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 @@ -1266,6 +1463,282 @@ impl Db { } }); } + + // ======================================================================== + // 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, + "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); + } + } + + /// 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); + } } struct Reaper { @@ -1296,25 +1769,21 @@ 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) + }) + }); } } @@ -1322,11 +1791,21 @@ impl Reaper { mod test { use crate::{ AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, - Prefix6, StaticRouteKey, db::Db, test::TestDb, types::PrefixDbKey, + Prefix6, StaticRouteKey, + db::Db, + test::{TestDb, mcast_v4, mcast_v6}, + types::{ + MulticastAddr, MulticastRoute, MulticastRouteKey, + MulticastRouteSource, PrefixDbKey, + }, }; use mg_common::log::*; 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"); @@ -1599,6 +2078,275 @@ 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 timeout = crate::test::TEST_TIMEOUT; + 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::source_specific(s_ip, group); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + db.add_static_mcast_routes(&[route]).unwrap(); + + // Initially should be selected + crate::test::wait_for( + || db.get_selected_mcast_route(&key).is_some(), + timeout, + "(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(); + + crate::test::wait_for( + || db.get_selected_mcast_route(&key).is_none(), + timeout, + "(S,G) remained selected after unicast route removed", + ); + + // Re-add unicast route + db.add_static_routes(&[srk]).unwrap(); + + // MRIB should be selected again + crate::test::wait_for( + || db.get_selected_mcast_route(&key).is_some(), + timeout, + "(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)), + mcast_v4(225, 1, 1, 1), + ); + + // 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)), + mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + ); + } + + /// 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 = mcast_v4(225, 5, 5, 5); + 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 + crate::test::wait_for( + || db.get_selected_mcast_route(&star_g_key).is_some(), + crate::test::TEST_TIMEOUT, + "(*,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 = mcast_v4(232, 1, 1, 1); // SSM range + let source = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 100)); + let sg_key = MulticastRouteKey::source_specific(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(); + + crate::test::wait_for( + || db.get_selected_mcast_route(&sg_key).is_some(), + crate::test::TEST_TIMEOUT, + "(S,G) should be selected after adding unicast route", + ); + + // Case: IPv6 (*,G) with global scope - goes to `mrib_loc` immediately + let v6_group = mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 0x5555]); + 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(); + + crate::test::wait_for( + || db.get_selected_mcast_route(&v6_star_g_key).is_some(), + crate::test::TEST_TIMEOUT, + "IPv6 (*,G) should be selected immediately", + ); + + // Case: IPv6 (S,G) with SSM address (ff3e::) + let v6_ssm_group = mcast_v6([0xff3e, 0, 0, 0, 0, 0, 0, 0x1234]); + let v6_source = + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100)); + let v6_sg_key = + MulticastRouteKey::source_specific(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(); + + crate::test::wait_for( + || db.get_selected_mcast_route(&v6_sg_key).is_some(), + crate::test::TEST_TIMEOUT, + "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 = mcast_v4(225, 2, 2, 2); + 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..607f2196 --- /dev/null +++ b/rdb/src/mrib/mod.rs @@ -0,0 +1,696 @@ +// 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::test::{mcast_v4, mcast_v6}; + + // 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 = mcast_v4(225, 1, 1, 1); + 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 = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let key_sg = MulticastRouteKey::source_specific(source, group); + 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 = mcast_v4(225, 3, 3, 3); + 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 = mcast_v4(225, 4, 4, 4); + 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 = mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]); + 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 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); + let key_sg = MulticastRouteKey::source_specific(source, group); + 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..e8b655a1 --- /dev/null +++ b/rdb/src/mrib/rpf.rs @@ -0,0 +1,1145 @@ +// 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| { + (u32::from(p.value), 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| { + (u128::from(p.value), 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 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 + crate::test::wait_for( + || rpf_table.cache_v4.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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); + crate::test::wait_for( + || rpf_table.cache_v4.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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); + crate::test::wait_for( + || rpf_table.cache_v4.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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); + crate::test::wait_for( + || rpf_table.cache_v4.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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 + crate::test::wait_for( + || rpf_table.cache_v6.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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); + crate::test::wait_for( + || rpf_table.cache_v4.read().unwrap().is_some(), + crate::test::TEST_TIMEOUT, + "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/test.rs b/rdb/src/test.rs index 8e4b27b3..46287b03 100644 --- a/rdb/src/test.rs +++ b/rdb/src/test.rs @@ -4,10 +4,16 @@ //! Test utilities for rdb tests. +use crate::types::{MulticastAddr, MulticastAddrV4, MulticastAddrV6}; use crate::{Db, error::Error}; use slog::Logger; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +/// Default timeout for waiting on async operations in tests. +pub const TEST_TIMEOUT: Duration = Duration::from_secs(5); /// A test database wrapper that automatically cleans up the database directory /// when dropped, but only if the test succeeded. @@ -136,3 +142,52 @@ pub fn get_test_db(test_name: &str, log: Logger) -> Result { let db = Db::new(&db_path, log)?; Ok(TestDb { db, path: db_path }) } + +/// Wait for a condition to become true, polling until timeout. +/// +/// This is useful for tests that need to wait for asynchronous operations +/// like background rebuilds or notifications to complete. +/// +/// This fn panics with the provided message if the condition doesn't become +/// `true` within the timeout. +pub fn wait_for(mut condition: F, timeout: Duration, msg: &str) +where + F: FnMut() -> bool, +{ + let start = std::time::Instant::now(); + while !condition() { + if start.elapsed() > timeout { + panic!("{msg}"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +// Multicast address test helpers + +/// Create a validated IPv4 multicast address for tests. +/// +/// Uses addresses in globally-routable ranges (not admin-scoped). +/// Panics if the address is not valid multicast. +pub fn mcast_v4(a: u8, b: u8, c: u8, d: u8) -> MulticastAddr { + MulticastAddr::V4(MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)).unwrap()) +} + +/// Create a validated IPv6 multicast address for tests. +/// +/// Panics if the address is not valid multicast. +pub fn mcast_v6(segments: [u16; 8]) -> MulticastAddr { + MulticastAddr::V6( + MulticastAddrV6::new(Ipv6Addr::new( + segments[0], + segments[1], + segments[2], + segments[3], + segments[4], + segments[5], + segments[6], + segments[7], + )) + .unwrap(), + ) +} diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 8fc19b9d..3f54baa8 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -5,6 +5,14 @@ use crate::error::Error; use anyhow::Result; use chrono::{DateTime, Utc}; +use omicron_common::address::{ + IPV4_ADMIN_SCOPED_MULTICAST_SUBNET, IPV4_GLOP_MULTICAST_SUBNET, + IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, + IPV4_SPECIFIC_RESERVED_MULTICAST_ADDRS, 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 schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::cmp::Ordering; @@ -255,6 +263,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 +477,945 @@ impl Display for PrefixChangeNotification { write!(f, "PrefixChangeNotification [ {pcn}]") } } + +// ============================================================================ +// MRIB (Multicast RIB) Types +// ============================================================================ + +/// Default VNI for fleet-wide multicast routing. +/// +/// Should match `omicron_common::api::external::Vni::DEFAULT_MULTICAST_VNI`. +pub const DEFAULT_MULTICAST_VNI: u32 = 77; + +/// A validated IPv4 multicast address. +/// +/// This type guarantees that the inner address is a routable multicast address +/// (not link-local, GLOP, admin-scoped, or otherwise reserved). +#[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) - not routed + 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" + ))); + } + + // Reject GLOP addresses (233.0.0.0/8) - AS-based allocation + if IPV4_GLOP_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv4 address {value} is in GLOP range \ + ({IPV4_GLOP_MULTICAST_SUBNET}) \ + which is reserved for AS-based allocation" + ))); + } + + // Reject admin-scoped addresses (239.0.0.0/8) - locally administered + if IPV4_ADMIN_SCOPED_MULTICAST_SUBNET.contains(value) { + return Err(Error::Validation(format!( + "IPv4 address {value} is admin-scoped \ + ({IPV4_ADMIN_SCOPED_MULTICAST_SUBNET}) \ + which is not globally routable" + ))); + } + + // Reject specific reserved addresses + if IPV4_SPECIFIC_RESERVED_MULTICAST_ADDRS.contains(&value) { + return Err(Error::Validation(format!( + "IPv4 address {value} is reserved and 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 interface-local multicast (ff01::/16) - not routed + 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) - not routed + 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" + ))); + } + + // 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" + ))); + } + + 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 { + /// 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), + } + } +} + +/// Multicast route key: (Source, Group) pair for source-specific multicast, +/// or (*, Group) for any-source multicast. +#[derive( + Debug, + Copy, + Clone, + Eq, + PartialEq, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub struct MulticastRouteKey { + /// Source address (None for any-source multicast (*,G)). + pub source: Option, + /// Multicast group address. + pub group: MulticastAddr, + /// VNI (Virtual Network Identifier) - defaults to 77 for fleet-wide + /// multicast, but allows future per-VPC multicast routing. + #[serde(default = "default_multicast_vni")] + pub vni: u32, +} + +const fn default_multicast_vni() -> u32 { + DEFAULT_MULTICAST_VNI +} + +impl fmt::Display for MulticastRouteKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self.source { + Some(src) => write!(f, "({},{})", src, self.group), + None => write!(f, "(*,{})", self.group), + } + } +} + +impl MulticastRouteKey { + /// Create an any-source multicast route (*,G) with default VNI. + pub fn any_source(group: MulticastAddr) -> Self { + Self { + source: None, + group, + vni: DEFAULT_MULTICAST_VNI, + } + } + + /// Create a source-specific multicast route (S,G) with default VNI. + pub fn source_specific(source: IpAddr, group: MulticastAddr) -> Self { + Self { + 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 { + Self { + source: None, + group, + vni, + } + } + + /// Create a source-specific multicast route (S,G) with specified VNI. + pub fn source_specific_with_vni( + source: IpAddr, + group: MulticastAddr, + vni: u32, + ) -> Self { + Self { + source: Some(source), + group, + 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 (232.0.0.0/8, ff3x::/12) require a source address + /// - Source address (if present) must be unicast + /// - Source address family must match group address family + /// - 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 (Source-Specific Multicast) groups require a source address + let is_ssm = match self.group { + MulticastAddr::V4(addr) => IPV4_SSM_SUBNET.contains(addr.ip()), + MulticastAddr::V6(addr) => IPV6_SSM_SUBNET.contains(addr.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 + if let Some(source) = &self.source { + // Source must be unicast (not multicast or broadcast) + match source { + IpAddr::V4(addr) => { + 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" + ))); + } + // Address family must match group + if !matches!(self.group, MulticastAddr::V4(_)) { + return Err(Error::Validation(format!( + "source address {addr} is IPv4 but group {} is IPv6", + self.group + ))); + } + } + IpAddr::V6(addr) => { + if addr.is_multicast() { + return Err(Error::Validation(format!( + "source address {addr} must be unicast, not multicast" + ))); + } + // Address family must match group + if !matches!(self.group, MulticastAddr::V6(_)) { + return Err(Error::Validation(format!( + "source address {addr} is IPv6 but group {} is IPv4", + self.group + ))); + } + } + } + } + + 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). Idempotent upserts with + /// identical values do 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]), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test::{mcast_v4, mcast_v6}; + + // MulticastAddr validation tests + + #[test] + fn multicast_addr_accepts_valid_ipv4() { + // 225.x.x.x is globally-routable multicast (not GLOP or admin-scoped) + let result = MulticastAddr::try_from(Ipv4Addr::new(225, 1, 2, 3)); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MulticastAddr::V4(_))); + } + + #[test] + fn multicast_addr_accepts_ipv4_ssm() { + // 232.x.x.x is SSM range + let result = MulticastAddr::try_from(Ipv4Addr::new(232, 1, 2, 3)); + assert!(result.is_ok()); + } + + #[test] + fn multicast_addr_rejects_ipv4_unicast() { + let result = MulticastAddr::try_from(Ipv4Addr::new(10, 0, 0, 1)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_rejects_ipv4_link_local() { + // 224.0.0.x is link-local, not routable + let result = MulticastAddr::try_from(Ipv4Addr::new(224, 0, 0, 1)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_rejects_ipv4_glop() { + // 233.x.x.x is GLOP (AS-based allocation), not globally routable + let result = MulticastAddr::try_from(Ipv4Addr::new(233, 1, 2, 3)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_rejects_ipv4_admin_scoped() { + // 239.x.x.x is admin-scoped, not globally routable + let result = MulticastAddr::try_from(Ipv4Addr::new(239, 1, 2, 3)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_accepts_valid_ipv6() { + // ff0e::1 is global scope multicast + let result = + MulticastAddr::try_from(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), MulticastAddr::V6(_))); + } + + #[test] + fn multicast_addr_accepts_ipv6_ssm() { + // ff3e::1 is SSM range + let result = + MulticastAddr::try_from(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 1)); + assert!(result.is_ok()); + } + + #[test] + fn multicast_addr_rejects_ipv6_unicast() { + let result = MulticastAddr::try_from(Ipv6Addr::new( + 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, + )); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_rejects_ipv6_link_local() { + // ff02::1 is link-local, not routable + let result = + MulticastAddr::try_from(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + #[test] + fn multicast_addr_rejects_ipv6_interface_local() { + // ff01::1 is interface-local, not routable + let result = + MulticastAddr::try_from(Ipv6Addr::new(0xff01, 0, 0, 0, 0, 0, 0, 1)); + assert!(matches!(result, Err(Error::Validation(_)))); + } + + // MulticastRouteKey validation tests + + #[test] + fn test_multicast_route_key_v4() { + // (*,G) valid (using 225.x which is globally routable) + let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + assert!(key.validate().is_ok(), "(*,G) should be valid"); + + // (S,G) valid + let key = MulticastRouteKey::source_specific( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + mcast_v4(225, 1, 2, 3), + ); + assert!(key.validate().is_ok(), "(S,G) should be valid"); + + // Multicast source rejected + let key = MulticastRouteKey::source_specific( + IpAddr::V4(Ipv4Addr::new(225, 1, 1, 1)), // multicast as source + mcast_v4(225, 1, 2, 3), + ); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "multicast source should be rejected" + ); + + // SSM (232.x.x.x) requires source + let key = MulticastRouteKey::any_source(mcast_v4(232, 1, 2, 3)); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "SSM without source should be rejected" + ); + + // SSM with source is valid + let key = MulticastRouteKey::source_specific( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + mcast_v4(232, 1, 2, 3), + ); + assert!(key.validate().is_ok(), "SSM with source should be valid"); + } + + #[test] + fn test_multicast_route_key_v6() { + // (*,G) valid (ff0e = global scope) + let key = MulticastRouteKey::any_source(mcast_v6([ + 0xff0e, 0, 0, 0, 0, 0, 0, 1, + ])); + assert!(key.validate().is_ok(), "(*,G) should be valid"); + + // (S,G) valid + let key = MulticastRouteKey::source_specific( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + ); + assert!(key.validate().is_ok(), "(S,G) should be valid"); + + // Multicast source rejected + let key = MulticastRouteKey::source_specific( + IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)), // mcast + mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 2]), + ); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "multicast source should be rejected" + ); + + // SSM (ff3x::) requires source + let key = MulticastRouteKey::any_source(mcast_v6([ + 0xff3e, 0, 0, 0, 0, 0, 0, 1, + ])); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "SSM without source should be rejected" + ); + + // SSM with source is valid + let key = MulticastRouteKey::source_specific( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + mcast_v6([0xff3e, 0, 0, 0, 0, 0, 0, 1]), + ); + assert!(key.validate().is_ok(), "SSM with source should be valid"); + } + + #[test] + fn test_multicast_route_key_af_mismatch() { + // IPv4 source with IPv6 group + let key = MulticastRouteKey::source_specific( + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + ); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "v4 source with v6 group should be rejected" + ); + + // IPv6 source with IPv4 group + let key = MulticastRouteKey::source_specific( + IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), + mcast_v4(225, 1, 2, 3), + ); + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "v6 source with v4 group should be rejected" + ); + } + + #[test] + fn test_multicast_route_key_vni() { + // VNI too large + let key = MulticastRouteKey { + source: None, + group: mcast_v4(225, 1, 2, 3), + vni: 1 << 24, // MAX_VNI + 1 + }; + assert!( + matches!(key.validate(), Err(Error::Validation(_))), + "VNI > 24 bits should be rejected" + ); + + // Max VNI is valid + let key = MulticastRouteKey { + source: None, + group: mcast_v4(225, 1, 2, 3), + vni: (1 << 24) - 1, // MAX_VNI + }; + assert!(key.validate().is_ok(), "max VNI should be valid"); + } + + // MulticastRoute validation tests + + // Valid admin-scoped underlay address for tests + const TEST_UNDERLAY: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); + + #[test] + fn test_multicast_route_v4() { + // Without RPF neighbor + let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + let route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + assert!( + route.validate().is_ok(), + "route without RPF should be valid" + ); + + // With valid unicast RPF neighbor + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert!( + route.validate().is_ok(), + "route with unicast RPF should be valid" + ); + + // With multicast RPF neighbor (invalid) + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(225, 1, 1, 1))); + assert!( + matches!(route.validate(), Err(Error::Validation(_))), + "multicast RPF neighbor should be rejected" + ); + } + + #[test] + fn test_multicast_route_v6() { + // With valid unicast RPF neighbor + let key = MulticastRouteKey::any_source(mcast_v6([ + 0xff0e, 0, 0, 0, 0, 0, 0, 1, + ])); + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + assert!( + route.validate().is_ok(), + "route with unicast RPF should be valid" + ); + + // With multicast RPF neighbor (invalid) + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 2))); + assert!( + matches!(route.validate(), Err(Error::Validation(_))), + "multicast RPF neighbor should be rejected" + ); + } + + #[test] + fn test_multicast_route_rpf_af_mismatch() { + // IPv4 RPF with IPv6 group + let key = MulticastRouteKey::any_source(mcast_v6([ + 0xff0e, 0, 0, 0, 0, 0, 0, 1, + ])); + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); + assert!( + matches!(route.validate(), Err(Error::Validation(_))), + "v4 RPF with v6 group should be rejected" + ); + + // IPv6 RPF with IPv4 group + let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + let mut route = MulticastRoute::new( + key, + TEST_UNDERLAY, + MulticastRouteSource::Static, + ); + route.rpf_neighbor = + Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); + assert!( + matches!(route.validate(), Err(Error::Validation(_))), + "v6 RPF with v4 group should be rejected" + ); + } +} From b7d0360eb7ef9d39684d33e11f4603a7d634c8b5 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 9 Dec 2025 13:18:44 +0000 Subject: [PATCH 2/7] [review] MRIB: Address PR feedback MRIB: API naming consistency and doc and validation fixes Address PR #576 review feedback: rename MRIB functions to match unicast patterns (get_mrib_*, read/update_mrib_*), fix stale doc comments, make API more flexible, and minor cleanups. --- Cargo.lock | 729 +++++++++++------- mg-api/src/lib.rs | 88 +-- mg-common/src/test.rs | 72 +- mgadm/src/mrib.rs | 393 +++++----- mgadm/src/static_routing.rs | 34 + mgd/src/admin.rs | 46 +- mgd/src/mrib_admin.rs | 135 ++-- ...ababb5.json => mg-admin-3.0.0-bb0197.json} | 373 +++++---- openapi/mg-admin/mg-admin-latest.json | 2 +- rdb/src/db.rs | 118 +-- rdb/src/mrib/mod.rs | 36 +- rdb/src/mrib/rpf.rs | 57 +- rdb/src/test.rs | 56 +- rdb/src/types.rs | 494 ++++++++---- 14 files changed, 1457 insertions(+), 1176 deletions(-) rename openapi/mg-admin/{mg-admin-3.0.0-ababb5.json => mg-admin-3.0.0-bb0197.json} (96%) diff --git a/Cargo.lock b/Cargo.lock index 43af7df8..573aab10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -119,18 +110,18 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] name = "api_identity" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -171,7 +162,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -193,7 +184,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -204,7 +195,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -268,21 +259,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.3.0" @@ -323,7 +299,7 @@ version = "0.1.0" dependencies = [ "anyhow", "mg-common", - "num_enum", + "num_enum 0.7.4", "pretty_assertions", "rand 0.8.5", "rdb", @@ -346,7 +322,7 @@ dependencies = [ "libnet", "mg-common", "nom", - "num_enum", + "num_enum 0.7.4", "oxnet", "pretty-hex", "pretty_assertions", @@ -401,7 +377,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.106", + "syn 2.0.111", "which", ] @@ -544,7 +520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.5", + "toml 0.9.8", ] [[package]] @@ -645,9 +621,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.47" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -655,9 +631,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -668,14 +644,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -687,7 +663,7 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clickhouse-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "atomicwrites", @@ -737,13 +713,13 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "cockroach-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "chrono", "csv", @@ -751,7 +727,7 @@ dependencies = [ "omicron-workspace-hack", "schemars", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -766,7 +742,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -981,7 +957,7 @@ dependencies = [ "libc", "num-derive 0.4.2", "num-traits", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1058,7 +1034,7 @@ checksum = "7ad40aef90652e771af668d28abcc3ef35fd0d39438706a76a61588cf8e8e84a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1092,7 +1068,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1106,7 +1082,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1117,7 +1093,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1128,7 +1104,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1283,7 +1259,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1292,7 +1268,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1313,7 +1289,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1324,7 +1300,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1337,7 +1313,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1365,7 +1341,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1375,9 +1351,9 @@ source = "git+https://github.com/oxidecomputer/dlpi-sys#555fa6e1315a64f40c72716e dependencies = [ "libc", "libdlpi-sys", - "num_enum", + "num_enum 0.7.4", "pretty-hex", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1386,7 +1362,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "558e5396321b677a59d2c43b3cc3bc44683109c63ac49275f3bbbf41c0ecd002" dependencies = [ - "goblin", + "goblin 0.8.2", "pretty-hex", "serde", "serde_json", @@ -1394,6 +1370,20 @@ dependencies = [ "zerocopy 0.7.35", ] +[[package]] +name = "dof" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed9b77e3c2a83995eedff2fbf992eef44c9f319b8016254f68108e27a4d06e7" +dependencies = [ + "goblin 0.10.4", + "pretty-hex", + "serde", + "serde_json", + "thiserror 2.0.17", + "zerocopy 0.8.27", +] + [[package]] name = "dpd-client" version = "0.1.0" @@ -1434,9 +1424,9 @@ dependencies = [ [[package]] name = "dropshot" -version = "0.16.4" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd9bdeafc752f117ed20e659b9763695ae5900adf3a32e93f9f6f4052fd5d66" +checksum = "3ee062e12b3b08e748f90e92efe0b93d53a28403bd900fd9e69e6be66bbddc4f" dependencies = [ "async-stream", "async-trait", @@ -1473,11 +1463,11 @@ dependencies = [ "slog-bunyan", "slog-json", "slog-term", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-rustls 0.25.0", - "toml 0.9.5", - "usdt", + "toml 0.9.8", + "usdt 0.6.0", "uuid", "version_check", "waitgroup", @@ -1510,7 +1500,7 @@ dependencies = [ "similar", "supports-color", "textwrap", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1528,9 +1518,9 @@ dependencies = [ [[package]] name = "dropshot_endpoint" -version = "0.16.4" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d09440e73a9dcf8a0f7fbd6ab889a7751d59f0fe76e5082a0a6d5623ec6da3" +checksum = "0430a45e1a3cc8aaf1396c2ca2a4c1215ab253cbcda7ca7ed7beb107e0591bd1" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1538,7 +1528,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_tokenstream", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1552,6 +1542,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "dtrace-parser" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc09b90bda5770641457f1c0a42c8203c48f5a3d9799dcf1bafbd84e30ccf080" +dependencies = [ + "pest", + "pest_derive", + "thiserror 2.0.17", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1606,7 +1607,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1615,10 +1616,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c138974f9d5e7fe373eb04df7cae98833802ae4b11c24ac7039a21d5af4b26c" +dependencies = [ + "serde", +] + [[package]] name = "ereport-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "dropshot", "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", @@ -1626,7 +1636,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1748,7 +1758,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1853,7 +1863,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -1898,7 +1908,7 @@ dependencies = [ [[package]] name = "gateway-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "base64 0.22.1", "chrono", @@ -1915,7 +1925,7 @@ dependencies = [ "serde", "serde_json", "slog", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "uuid", ] @@ -1940,7 +1950,7 @@ dependencies = [ [[package]] name = "gateway-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "daft", "dropshot", @@ -1951,7 +1961,7 @@ dependencies = [ "omicron-workspace-hack", "schemars", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "tufaceous-artifact", "uuid", ] @@ -2017,12 +2027,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.3" @@ -2050,7 +2054,18 @@ checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" dependencies = [ "log", "plain", - "scroll", + "scroll 0.12.0", +] + +[[package]] +name = "goblin" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4db6758c546e6f81f265638c980e5e84dfbda80cfd8e89e02f83454c8e8124bd" +dependencies = [ + "log", + "plain", + "scroll 0.13.0", ] [[package]] @@ -2104,9 +2119,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", ] @@ -2190,7 +2205,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "ring", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tokio", "tracing", @@ -2234,7 +2249,7 @@ dependencies = [ "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -2351,9 +2366,9 @@ checksum = "9b112acc8b3adf4b107a8ec20977da0273a8c386765a3ec0229bd500a1443f9f" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -2391,9 +2406,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", @@ -2407,9 +2422,10 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration", "tokio", + "tower-layer", "tower-service", "tracing", "windows-registry", @@ -2547,7 +2563,7 @@ dependencies = [ "daft", "equivalent", "foldhash 0.2.0", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "ref-cast", "rustc-hash 2.1.1", "schemars", @@ -2582,6 +2598,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "illumos-devinfo" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/illumos-devinfo?branch=main#4323b17bfdd0c94d2875ac64b47f0e60fac1d640" +dependencies = [ + "anyhow", + "libc", + "num_enum 0.5.11", +] + [[package]] name = "illumos-sys-hdrs" version = "0.1.0" @@ -2601,7 +2627,7 @@ dependencies = [ [[package]] name = "illumos-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "async-trait", @@ -2619,6 +2645,7 @@ dependencies = [ "itertools 0.14.0", "libc", "macaddr", + "nix", "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-uuid-kinds 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "omicron-workspace-hack", @@ -2631,7 +2658,8 @@ dependencies = [ "slog", "slog-error-chain", "smf 0.2.3", - "thiserror 2.0.16", + "thiserror 2.0.17", + "tofino", "tokio", "uuid", "whoami", @@ -2646,12 +2674,12 @@ checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "serde", "serde_core", ] @@ -2694,7 +2722,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -2720,7 +2748,7 @@ dependencies = [ [[package]] name = "internal-dns-resolver" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "futures", "hickory-proto 0.25.2", @@ -2732,13 +2760,13 @@ dependencies = [ "qorb", "reqwest", "slog", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "internal-dns-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "chrono", @@ -2750,17 +2778,6 @@ dependencies = [ "strum 0.27.2", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ipconfig" version = "0.3.2" @@ -2877,7 +2894,7 @@ checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -2921,7 +2938,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=3d1263ced8177893d46da54a914e4c510dc2bfc8#3d1263ced8177893d46da54a914e4c510dc2bfc8" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -2930,7 +2947,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80#795a1e0aeefb7a2c6fe4139779fdf66930d09b80" dependencies = [ "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -2994,12 +3011,12 @@ dependencies = [ "slog-envlogger", "slog-term", "smf 0.2.3", - "syn 2.0.106", + "syn 2.0.111", "tabwriter", "thiserror 1.0.69", "tokio", "tokio-tungstenite", - "toml 0.9.5", + "toml 0.9.8", "uuid", "xz2", "zone 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3012,7 +3029,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.3", ] [[package]] @@ -3031,14 +3048,14 @@ dependencies = [ "colored", "dlpi", "libc", - "num_enum", + "num_enum 0.7.4", "nvpair", "nvpair-sys", "oxnet", "rand 0.9.2", "rusty-doors", "socket2 0.6.0", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "winnow 0.7.13", ] @@ -3523,7 +3540,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -3538,7 +3555,7 @@ dependencies = [ [[package]] name = "nexus-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "chrono", "futures", @@ -3561,7 +3578,7 @@ dependencies = [ [[package]] name = "nexus-sled-agent-shared" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "camino", "chrono", @@ -3578,7 +3595,7 @@ dependencies = [ "serde_json", "sled-hardware-types", "strum 0.27.2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tufaceous-artifact", "uuid", ] @@ -3586,7 +3603,7 @@ dependencies = [ [[package]] name = "nexus-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "api_identity 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", @@ -3640,7 +3657,7 @@ dependencies = [ "tabled 0.15.0", "test-strategy", "textwrap", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tough", "tufaceous-artifact", @@ -3738,7 +3755,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -3781,26 +3798,47 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive 0.5.11", +] + [[package]] name = "num_enum" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" dependencies = [ - "num_enum_derive", + "num_enum_derive 0.7.4", "rustversion", ] +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num_enum_derive" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -3827,15 +3865,6 @@ name = "nvpair-sys" version = "0.4.0" source = "git+https://github.com/jmesmon/rust-libzfs?branch=master#ecd5a922247a6c5acef55d76c5b8d115572bc850" -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "olpc-cjson" version = "0.1.4" @@ -3886,7 +3915,7 @@ dependencies = [ "slog", "slog-error-chain", "strum 0.27.2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tufaceous-artifact", "uuid", @@ -3895,7 +3924,7 @@ dependencies = [ [[package]] name = "omicron-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "api_identity 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", @@ -3930,7 +3959,7 @@ dependencies = [ "slog", "slog-error-chain", "strum 0.27.2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tufaceous-artifact", "uuid", @@ -3939,7 +3968,7 @@ dependencies = [ [[package]] name = "omicron-passwords" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "argon2", "omicron-workspace-hack", @@ -3948,7 +3977,7 @@ dependencies = [ "secrecy", "serde", "serde_with", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -3966,7 +3995,7 @@ dependencies = [ [[package]] name = "omicron-uuid-kinds" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "daft", "newtype-uuid", @@ -4063,7 +4092,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4152,7 +4181,7 @@ dependencies = [ "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=3d1263ced8177893d46da54a914e4c510dc2bfc8)", "postcard", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -4166,7 +4195,7 @@ dependencies = [ "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80)", "postcard", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -4228,7 +4257,7 @@ dependencies = [ "oximeter-timeseries-macro 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", "prettyplease", - "syn 2.0.106", + "syn 2.0.111", "toml 0.8.23", "uuid", ] @@ -4236,7 +4265,7 @@ dependencies = [ [[package]] name = "oximeter" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "chrono", @@ -4247,7 +4276,7 @@ dependencies = [ "oximeter-timeseries-macro 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "prettyplease", - "syn 2.0.106", + "syn 2.0.111", "toml 0.8.23", "uuid", ] @@ -4255,7 +4284,7 @@ dependencies = [ [[package]] name = "oximeter-db" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "async-recursion", @@ -4298,10 +4327,10 @@ dependencies = [ "slog-term", "strum 0.27.2", "termtree", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-util", - "usdt", + "usdt 0.5.0", "uuid", ] @@ -4313,24 +4342,24 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] name = "oximeter-macro-impl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] name = "oximeter-producer" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "chrono", "dropshot", @@ -4344,7 +4373,7 @@ dependencies = [ "serde", "slog", "slog-dtrace", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "uuid", ] @@ -4366,14 +4395,14 @@ dependencies = [ "schemars", "serde", "slog-error-chain", - "syn 2.0.106", + "syn 2.0.111", "toml 0.8.23", ] [[package]] name = "oximeter-schema" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "chrono", @@ -4387,7 +4416,7 @@ dependencies = [ "schemars", "serde", "slog-error-chain", - "syn 2.0.106", + "syn 2.0.111", "toml 0.8.23", ] @@ -4401,20 +4430,20 @@ dependencies = [ "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=main)", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] name = "oximeter-timeseries-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "omicron-workspace-hack", "oximeter-schema 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "oximeter-types 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4433,14 +4462,14 @@ dependencies = [ "schemars", "serde", "strum 0.27.2", - "thiserror 2.0.16", + "thiserror 2.0.17", "uuid", ] [[package]] name = "oximeter-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "bytes", "chrono", @@ -4453,14 +4482,14 @@ dependencies = [ "schemars", "serde", "strum 0.27.2", - "thiserror 2.0.16", + "thiserror 2.0.17", "uuid", ] [[package]] name = "oxlog" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "camino", @@ -4489,7 +4518,7 @@ dependencies = [ [[package]] name = "oxql-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "chrono", @@ -4595,7 +4624,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4638,7 +4667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" dependencies = [ "memchr", - "thiserror 2.0.16", + "thiserror 2.0.17", "ucd-trie", ] @@ -4662,7 +4691,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4734,7 +4763,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4840,7 +4869,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.106", + "syn 2.0.111", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", ] [[package]] @@ -4895,7 +4934,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -4975,8 +5014,8 @@ dependencies = [ "schemars", "serde", "serde_json", - "syn 2.0.106", - "thiserror 2.0.16", + "syn 2.0.111", + "thiserror 2.0.17", "typify", "unicode-ident", ] @@ -4997,8 +5036,8 @@ dependencies = [ "schemars", "serde", "serde_json", - "syn 2.0.106", - "thiserror 2.0.16", + "syn 2.0.111", + "thiserror 2.0.17", "typify", "unicode-ident", ] @@ -5018,7 +5057,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5036,7 +5075,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5130,11 +5169,11 @@ dependencies = [ "hickory-resolver 0.24.4", "rand 0.9.2", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", - "usdt", + "usdt 0.5.0", ] [[package]] @@ -5156,8 +5195,8 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.31", - "socket2 0.5.10", - "thiserror 2.0.16", + "socket2 0.6.0", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -5178,7 +5217,7 @@ dependencies = [ "rustls 0.23.31", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -5193,7 +5232,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", "windows-sys 0.60.2", ] @@ -5369,7 +5408,7 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5486,7 +5525,7 @@ checksum = "a5a11a05ee1ce44058fa3d5961d05194fdbe3ad6b40f904af764d81b86450e6b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5516,12 +5555,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -5736,7 +5769,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5757,7 +5790,16 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" dependencies = [ - "scroll_derive", + "scroll_derive 0.12.1", +] + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive 0.13.1", ] [[package]] @@ -5768,7 +5810,18 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", +] + +[[package]] +name = "scroll_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -5838,7 +5891,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5849,7 +5902,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5876,12 +5929,13 @@ dependencies = [ [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", ] [[package]] @@ -5901,7 +5955,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5915,11 +5969,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -5931,7 +5985,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -5971,7 +6025,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6009,7 +6063,7 @@ checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6107,7 +6161,7 @@ dependencies = [ [[package]] name = "sled-hardware-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "illumos-utils", "omicron-common 0.1.0 (git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle)", @@ -6118,9 +6172,15 @@ dependencies = [ [[package]] name = "slog" -version = "2.7.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8347046d4ebd943127157b94d63abb990fcf729dc4e9978927fdf4ac3c998d06" +checksum = "9b3b8565691b22d2bdfc066426ed48f837fc0c5f2c8cad8d9718f7f99d6995c1" +dependencies = [ + "anyhow", + "erased-serde", + "rustversion", + "serde_core", +] [[package]] name = "slog-async" @@ -6156,7 +6216,7 @@ dependencies = [ "serde", "serde_json", "slog", - "usdt", + "usdt 0.5.0", "version_check", ] @@ -6191,7 +6251,7 @@ source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6331,10 +6391,10 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6418,7 +6478,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6429,7 +6489,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6460,7 +6520,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6472,7 +6532,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6509,9 +6569,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -6535,7 +6595,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6604,7 +6664,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6687,7 +6747,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6731,11 +6791,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -6746,18 +6806,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6770,6 +6830,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "thread-id" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99043e46c5a15af379c06add30d9c93a6c0e8849de00d244c4a2c417da128d80" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -6845,24 +6915,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tofino" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/tofino#7e56ab6e9a64ebae27cd97cd6e10ebf2cfdc3a33" +dependencies = [ + "anyhow", + "cc", + "illumos-devinfo", +] + [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.0", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -6871,20 +6948,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5eb4bcf85c373ff09a8beb87a477c2df185cd8842a770386a88bc3ff7ac5abb" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", - "usdt", + "usdt 0.5.0", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -6972,14 +7049,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.5" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ "indexmap", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", + "serde_core", + "serde_spanned 1.0.3", + "toml_datetime 0.7.3", "toml_parser", "toml_writer", "winnow 0.7.13", @@ -6996,11 +7073,11 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -7032,9 +7109,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow 0.7.13", ] @@ -7047,9 +7124,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "topological-sort" @@ -7156,7 +7233,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -7214,11 +7291,11 @@ dependencies = [ "slog-async", "slog-term", "tabled 0.20.0", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "transceiver-decode", "transceiver-messages", - "usdt", + "usdt 0.5.0", "version_check", ] @@ -7230,7 +7307,7 @@ dependencies = [ "schemars", "serde", "static_assertions", - "thiserror 2.0.16", + "thiserror 2.0.17", "transceiver-messages", ] @@ -7244,7 +7321,7 @@ dependencies = [ "hubpack", "schemars", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -7267,7 +7344,7 @@ dependencies = [ "serde_human_bytes", "strum 0.26.3", "test-strategy", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -7326,8 +7403,8 @@ dependencies = [ "semver 1.0.27", "serde", "serde_json", - "syn 2.0.106", - "thiserror 2.0.16", + "syn 2.0.111", + "thiserror 2.0.17", "unicode-ident", ] @@ -7344,7 +7421,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.106", + "syn 2.0.111", "typify-impl", ] @@ -7432,7 +7509,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "update-engine" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#655e6028c1bb916ca5d70d1d37fad785a1fbf897" +source = "git+https://github.com/oxidecomputer/omicron?branch=zl%2Fmcast-implicit-lifecycle#07a36a1dabacaec9ebfe9fa48ab51adf99a67278" dependencies = [ "anyhow", "cancel-safe-futures", @@ -7477,14 +7554,29 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bf5c47fb471a0bff3d7b17a250817bba8c6cc99b0492abaefe5b3bb99045f02" dependencies = [ - "dof", - "dtrace-parser", - "goblin", + "dof 0.3.0", + "dtrace-parser 0.2.0", + "goblin 0.8.2", "memmap", "serde", - "usdt-attr-macro", - "usdt-impl", - "usdt-macro", + "usdt-attr-macro 0.5.0", + "usdt-impl 0.5.0", + "usdt-macro 0.5.0", +] + +[[package]] +name = "usdt" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1953f8d8a98ac7883c230963291acb65c7ed6ae3e2e4c99d5c65f4e65cc9db38" +dependencies = [ + "dof 0.4.0", + "goblin 0.10.4", + "memmap2", + "serde", + "usdt-attr-macro 0.6.0", + "usdt-impl 0.6.0", + "usdt-macro 0.6.0", ] [[package]] @@ -7493,12 +7585,26 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "025161fff40db24774e7757f75df74ecc47e93d7e11e0f6cdfc31b40eacfe136" dependencies = [ - "dtrace-parser", + "dtrace-parser 0.2.0", "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.106", - "usdt-impl", + "syn 2.0.111", + "usdt-impl 0.5.0", +] + +[[package]] +name = "usdt-attr-macro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d0d673848744c556fcfe8479f87b6974459106e4c41f38375f6d559bb0ee28" +dependencies = [ + "dtrace-parser 0.3.0", + "proc-macro2", + "quote", + "serde_tokenstream", + "syn 2.0.111", + "usdt-impl 0.6.0", ] [[package]] @@ -7508,31 +7614,64 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f925814e5942ebb87af2d9fcf4c3f8665e37903f741eb11f0fa2396c6ef5f7b1" dependencies = [ "byteorder", - "dof", - "dtrace-parser", + "dof 0.3.0", + "dtrace-parser 0.2.0", "libc", "proc-macro2", "quote", "serde", "serde_json", - "syn 2.0.106", + "syn 2.0.111", "thiserror 1.0.69", - "thread-id", + "thread-id 4.2.2", "version_check", ] +[[package]] +name = "usdt-impl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf0085a93af1ca095d8b1dc8672cc4620fcd1db5dff8d165486067badce05555" +dependencies = [ + "byteorder", + "dof 0.4.0", + "dtrace-parser 0.3.0", + "libc", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.111", + "thiserror 2.0.17", + "thread-id 5.0.0", +] + [[package]] name = "usdt-macro" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ddd86f8f3abac0b7c87f59fe82446fc96a3854a413f176dd2797ed686b7af4c" dependencies = [ - "dtrace-parser", + "dtrace-parser 0.2.0", + "proc-macro2", + "quote", + "serde_tokenstream", + "syn 2.0.111", + "usdt-impl 0.5.0", +] + +[[package]] +name = "usdt-macro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9bf594f86b676f7e2fd3d523d50f9d0cffecff6c19729ff5dbebe86c4cb8cb2" +dependencies = [ + "dtrace-parser 0.3.0", "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.106", - "usdt-impl", + "syn 2.0.111", + "usdt-impl 0.6.0", ] [[package]] @@ -7676,7 +7815,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -7711,7 +7850,7 @@ checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -7881,7 +8020,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -7892,7 +8031,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -8280,7 +8419,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", "synstructure", ] @@ -8311,7 +8450,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -8322,7 +8461,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] @@ -8342,7 +8481,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", "synstructure", ] @@ -8382,7 +8521,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn 2.0.111", ] [[package]] diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 7355cb03..5e68f543 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -380,73 +380,61 @@ pub trait MgAdminApi { // Direct operator configuration should go through the Oxide API to // maintain consistency with the control plane's view of group membership. - /// Get all imported multicast routes (`mrib_in`). + /// 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 mrib_status_imported( - rqctx: RequestContext, - query: Query, - ) -> Result>, HttpError>; - - /// Get installed multicast routes (`mrib_loc`, RPF-validated). - #[endpoint { method = GET, path = "/mrib/status/installed", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_status_installed( + async fn get_mrib_imported( rqctx: RequestContext, - query: Query, + query: Query, ) -> Result>, HttpError>; - /// Get a specific multicast route by key. - #[endpoint { method = GET, path = "/mrib/route", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_get_route( - rqctx: RequestContext, - query: Query, - ) -> Result, HttpError>; - - /// Get a specific installed multicast route by key (`mrib_loc`). + /// Get selected multicast routes (`mrib_loc`, RPF-validated). /// - /// Same query parameters as `mrib_get_route`, but returns the RPF-selected - /// route from the installed table. Useful for verifying RPF neighbor and - /// dataplane eligibility. - #[endpoint { method = GET, path = "/mrib/route/installed", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_get_selected_route( + /// 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>; + 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 = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_static_add( + #[endpoint { method = PUT, path = "/static/mroute", versions = VERSION_MULTICAST_SUPPORT.. }] + async fn static_add_mcast_route( rqctx: RequestContext, request: TypedBody, ) -> Result; - /// Delete static multicast routes. + /// 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 = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_static_delete( + #[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 = "/mrib/static/route", versions = VERSION_MULTICAST_SUPPORT.. }] - async fn mrib_static_list( + #[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 mrib_get_rpf_rebuild_interval( + 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 mrib_set_rpf_rebuild_interval( + async fn update_mrib_rpf_rebuild_interval( rqctx: RequestContext, request: TypedBody, ) -> Result; @@ -702,28 +690,30 @@ pub enum RouteOriginFilter { Dynamic, } -/// Query parameters for listing MRIB routes. +/// 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 MribStatusQuery { - /// Filter by address family (`None` returns all). +pub struct MribQuery { + /// Multicast group address. If provided, returns a specific route. + /// If omitted, returns all routes matching the filters. #[serde(default)] - pub address_family: Option, - /// Filter by route origin, i.e. "Static" or "Dynamic" - /// (`None` returns all). + pub group: Option, + /// Source address (`None` for (*,G) routes). Only used when `group` is set. #[serde(default)] - pub route_origin: Option, -} - -/// Query parameters for looking up a specific MRIB route. -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -pub struct MribRouteQuery { - /// Source address (`None` for ASM (*,G)). pub source: Option, - /// Multicast group address. - pub group: IpAddr, /// 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 { diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index b0f027de..ab2bce0f 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 ); }; diff --git a/mgadm/src/mrib.rs b/mgadm/src/mrib.rs index 8642be78..36a37e5c 100644 --- a/mgadm/src/mrib.rs +++ b/mgadm/src/mrib.rs @@ -12,22 +12,23 @@ use std::net::IpAddr; use anyhow::Result; -use clap::{Args, Subcommand, ValueEnum}; +use clap::{Args, Subcommand}; use mg_admin_client::Client; use mg_admin_client::types::{ - MribRpfRebuildIntervalRequest, MulticastAddr, MulticastRoute, + MribRpfRebuildIntervalRequest, MulticastRoute, MulticastRouteKey, RouteOriginFilter, }; -use rdb::types::AddressFamily; - -/// Filter for route origin. -#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)] -pub enum RouteOrigin { - /// Static routes only (manually configured). - Static, - /// Dynamic routes only (learned via IGMP, MLD, etc.). - Dynamic, +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)] @@ -35,17 +36,8 @@ pub enum Commands { /// View MRIB state. Status(StatusCommand), - /// Get a specific multicast route by key. - Get(GetCommand), - - /// Get a specific installed multicast route (mrib_loc). - GetInstalled(GetCommand), - - /// RPF rebuild configuration. + /// RPF (Reverse Path Forwarding) table configuration and lookup. Rpf(RpfCommand), - - /// Static multicast route management. - Static(StaticCommand), } #[derive(Debug, Args)] @@ -56,42 +48,59 @@ pub struct StatusCommand { #[derive(Subcommand, Debug)] pub enum StatusCmd { - /// Get all imported multicast routes (`mrib_in`). + /// 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 { - /// Filter by address family. - #[arg(short, long, value_enum)] - af: Option, + /// Address family to filter by. + #[arg(value_enum)] + address_family: Option, - /// Filter by route origin ("static" or "dynamic"). - #[arg(long, value_enum)] - origin: 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, - /// Get installed multicast routes (`mrib_loc`, RPF-validated). - Installed { - /// Filter by address family. - #[arg(short, long, value_enum)] - af: 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_enum)] - origin: Option, + #[arg(long, value_parser = parse_route_origin)] + origin: Option, }, -} -#[derive(Debug, Args)] -pub struct GetCommand { - /// Multicast group address. - #[arg(short, long)] - group: IpAddr, + /// 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, - /// Source address (omit for any-source (*,G)). - #[arg(short, long)] - source: Option, + /// Multicast group address (if omitted, lists all routes). + #[arg(short, long)] + group: Option, - /// VNI (defaults to 77 for fleet-scoped multicast). - #[arg(short, long, default_value_t = 77)] - vni: u32, + /// 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)] @@ -112,76 +121,66 @@ pub enum RpfCmd { }, } -#[derive(Debug, Args)] -pub struct StaticCommand { - #[command(subcommand)] - command: StaticRouteCmd, -} - -#[derive(Subcommand, Debug)] -pub enum StaticRouteCmd { - /// List all static multicast routes. - List, -} - pub async fn commands(command: Commands, c: Client) -> Result<()> { match command { Commands::Status(status_cmd) => match status_cmd.command { - StatusCmd::Imported { af, origin } => { - get_imported(c, af, origin).await? + 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::Installed { af, origin } => { - get_installed(c, af, 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::Get(get_cmd) => { - get_route(c, get_cmd.group, get_cmd.source, get_cmd.vni).await? - } - Commands::GetInstalled(get_cmd) => { - get_route_installed(c, get_cmd.group, get_cmd.source, get_cmd.vni) - .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? } }, - Commands::Static(static_cmd) => match static_cmd.command { - StaticRouteCmd::List => static_list(c).await?, - }, } Ok(()) } async fn get_imported( c: Client, - af: Option, - origin: Option, + address_family: Option, + origin: Option, ) -> Result<()> { - let origin_filter = origin.map(|o| match o { - RouteOrigin::Static => RouteOriginFilter::Static, - RouteOrigin::Dynamic => RouteOriginFilter::Dynamic, - }); let routes = c - .mrib_status_imported(af.as_ref(), origin_filter) + .get_mrib_imported(address_family.as_ref(), None, origin, None, None) .await? .into_inner(); print_routes(&routes); Ok(()) } -async fn get_installed( +async fn get_selected( c: Client, - af: Option, - origin: Option, + address_family: Option, + origin: Option, ) -> Result<()> { - let origin_filter = origin.map(|o| match o { - RouteOrigin::Static => RouteOriginFilter::Static, - RouteOrigin::Dynamic => RouteOriginFilter::Dynamic, - }); let routes = c - .mrib_status_installed(af.as_ref(), origin_filter) + .get_mrib_selected(address_family.as_ref(), None, origin, None, None) .await? .into_inner(); print_routes(&routes); @@ -194,36 +193,44 @@ async fn get_route( source: Option, vni: u32, ) -> Result<()> { - let route = c - .mrib_get_route(&group, source.as_ref(), Some(vni)) + let routes = c + .get_mrib_imported(None, Some(&group), None, source.as_ref(), Some(vni)) .await? .into_inner(); - println!("{route:#?}"); + if let Some(route) = routes.first() { + println!("{route:#?}"); + } else { + anyhow::bail!("route not found"); + } Ok(()) } -async fn get_route_installed( +async fn get_route_selected( c: Client, group: IpAddr, source: Option, vni: u32, ) -> Result<()> { - let route = c - .mrib_get_selected_route(&group, source.as_ref(), Some(vni)) + let routes = c + .get_mrib_selected(None, Some(&group), None, source.as_ref(), Some(vni)) .await? .into_inner(); - println!("{route:#?}"); + 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.mrib_get_rpf_rebuild_interval().await?.into_inner(); + 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.mrib_set_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { + c.update_mrib_rpf_rebuild_interval(&MribRpfRebuildIntervalRequest { interval_ms, }) .await?; @@ -231,34 +238,26 @@ async fn set_rpf_interval(c: Client, interval_ms: u64) -> Result<()> { Ok(()) } -async fn static_list(c: Client) -> Result<()> { - let routes = c.mrib_static_list().await?.into_inner(); - if routes.is_empty() { - println!("No static multicast routes"); - } else { - print_routes(&routes); - } - Ok(()) -} - fn print_routes(routes: &[MulticastRoute]) { if routes.is_empty() { println!("No multicast routes"); return; } for route in routes { - let key = &route.key; - let source_str = match &key.source { - Some(s) => s.to_string(), - None => "*".to_string(), - }; - let group_str = match &key.group { - MulticastAddr::V4(v4) => v4.to_string(), - MulticastAddr::V6(v6) => v6.to_string(), + 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={} underlay={} rpf={:?} nexthops={} source={:?}", - key.vni, + "({source_str},{group_str}) vni={vni} underlay={} rpf={:?} nexthops={} source={:?}", route.underlay_group, route.rpf_neighbor, route.underlay_nexthops.len(), @@ -281,25 +280,40 @@ mod tests { } #[test] - fn test_get_command_group_only() { - let cli = TestCli::try_parse_from(["test", "get", "-g", "225.1.2.3"]) - .unwrap(); + 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::Get(cmd) => { - assert_eq!(cmd.group, IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))); - assert_eq!(cmd.source, None); - assert_eq!(cmd.vni, 77); // default - } - _ => panic!("expected Get 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_get_command_all_flags() { + fn test_status_imported_specific_route_all_flags() { let cli = TestCli::try_parse_from([ "test", - "get", + "status", + "imported", "-g", "225.1.2.3", "-s", @@ -310,23 +324,32 @@ mod tests { .unwrap(); match cli.command { - Commands::Get(cmd) => { - assert_eq!(cmd.group, IpAddr::V4(Ipv4Addr::new(225, 1, 2, 3))); - assert_eq!( - cmd.source, - Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))) - ); - assert_eq!(cmd.vni, 100); - } - _ => panic!("expected Get 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_get_command_ipv6() { + fn test_status_selected_specific_route_ipv6() { let cli = TestCli::try_parse_from([ "test", - "get", + "status", + "selected", "--group", "ff0e::1", "--source", @@ -337,35 +360,45 @@ mod tests { .unwrap(); match cli.command { - Commands::Get(cmd) => { - assert_eq!( - cmd.group, - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) - ); - assert_eq!( - cmd.source, - Some(IpAddr::V6(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1 - ))) - ); - assert_eq!(cmd.vni, 42); - } - _ => panic!("expected Get 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_with_af() { - let cli = TestCli::try_parse_from([ - "test", "status", "imported", "-a", "ipv4", - ]) - .unwrap(); + 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 { af, origin } => { - assert_eq!(af, Some(AddressFamily::Ipv4)); - assert_eq!(origin, None); + StatusCmd::Imported { + group, + address_family, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, Some(AddressFamily::Ipv4)); } _ => panic!("expected Imported"), }, @@ -374,7 +407,7 @@ mod tests { } #[test] - fn test_status_imported_with_origin() { + fn test_status_imported_list_with_origin() { let cli = TestCli::try_parse_from([ "test", "status", "imported", "--origin", "dynamic", ]) @@ -382,9 +415,9 @@ mod tests { match cli.command { Commands::Status(cmd) => match cmd.command { - StatusCmd::Imported { af, origin } => { - assert_eq!(af, None); - assert_eq!(origin, Some(RouteOrigin::Dynamic)); + StatusCmd::Imported { group, origin, .. } => { + assert_eq!(group, None); + assert_eq!(origin, Some(RouteOriginFilter::Dynamic)); } _ => panic!("expected Imported"), }, @@ -393,17 +426,23 @@ mod tests { } #[test] - fn test_status_installed_no_af() { + fn test_status_selected_list_all() { let cli = - TestCli::try_parse_from(["test", "status", "installed"]).unwrap(); + TestCli::try_parse_from(["test", "status", "selected"]).unwrap(); match cli.command { Commands::Status(cmd) => match cmd.command { - StatusCmd::Installed { af, origin } => { - assert_eq!(af, None); + StatusCmd::Selected { + group, + address_family, + origin, + .. + } => { + assert_eq!(group, None); + assert_eq!(address_family, None); assert_eq!(origin, None); } - _ => panic!("expected Installed"), + _ => panic!("expected Selected"), }, _ => panic!("expected Status command"), } @@ -425,16 +464,4 @@ mod tests { _ => panic!("expected Rpf command"), } } - - #[test] - fn test_static_list() { - let cli = TestCli::try_parse_from(["test", "static", "list"]).unwrap(); - - match cli.command { - Commands::Static(cmd) => { - assert!(matches!(cmd.command, StaticRouteCmd::List)); - } - _ => panic!("expected Static 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 74fe5f7a..44c38d44 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -439,68 +439,54 @@ impl MgAdminApi for MgAdminApiImpl { static_admin::static_list_v6_routes(ctx).await } - async fn mrib_status_imported( + async fn get_mrib_imported( rqctx: RequestContext, - query: Query, + query: Query, ) -> Result>, HttpError> { - mrib_admin::mrib_status_imported(rqctx, query).await + mrib_admin::get_mrib_imported(rqctx, query).await } - async fn mrib_status_installed( + async fn get_mrib_selected( rqctx: RequestContext, - query: Query, + query: Query, ) -> Result>, HttpError> { - mrib_admin::mrib_status_installed(rqctx, query).await + mrib_admin::get_mrib_selected(rqctx, query).await } - async fn mrib_get_route( - rqctx: RequestContext, - query: Query, - ) -> Result, HttpError> { - mrib_admin::mrib_get_route(rqctx, query).await - } - - async fn mrib_get_selected_route( - rqctx: RequestContext, - query: Query, - ) -> Result, HttpError> { - mrib_admin::mrib_get_selected_route(rqctx, query).await - } - - async fn mrib_static_add( + async fn static_add_mcast_route( rqctx: RequestContext, request: TypedBody, ) -> Result { - mrib_admin::mrib_static_add(rqctx, request).await + mrib_admin::static_add_mcast_route(rqctx, request).await } - async fn mrib_static_delete( + async fn static_remove_mcast_route( rqctx: RequestContext, request: TypedBody, ) -> Result { - mrib_admin::mrib_static_delete(rqctx, request).await + mrib_admin::static_remove_mcast_route(rqctx, request).await } - async fn mrib_static_list( + async fn static_list_mcast_routes( rqctx: RequestContext, ) -> Result>, HttpError> { - mrib_admin::mrib_static_list(rqctx).await + mrib_admin::static_list_mcast_routes(rqctx).await } - async fn mrib_get_rpf_rebuild_interval( + async fn read_mrib_rpf_rebuild_interval( rqctx: RequestContext, ) -> Result, HttpError> { - mrib_admin::mrib_get_rpf_rebuild_interval(rqctx).await + mrib_admin::read_mrib_rpf_rebuild_interval(rqctx).await } - async fn mrib_set_rpf_rebuild_interval( + async fn update_mrib_rpf_rebuild_interval( rqctx: RequestContext, request: TypedBody, ) -> Result { - mrib_admin::mrib_set_rpf_rebuild_interval(rqctx, request).await + mrib_admin::update_mrib_rpf_rebuild_interval(rqctx, request).await } } diff --git a/mgd/src/mrib_admin.rs b/mgd/src/mrib_admin.rs index c5c4d952..fa2b376f 100644 --- a/mgd/src/mrib_admin.rs +++ b/mgd/src/mrib_admin.rs @@ -2,7 +2,6 @@ // 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::net::IpAddr; use std::sync::Arc; use std::time::Duration; @@ -12,9 +11,9 @@ use dropshot::{ }; use mg_api::{ - MribAddStaticRequest, MribDeleteStaticRequest, + MribAddStaticRequest, MribDeleteStaticRequest, MribQuery, MribRpfRebuildIntervalRequest, MribRpfRebuildIntervalResponse, - MribStatusQuery, RouteOriginFilter, + RouteOriginFilter, }; use rdb::types::{ MulticastAddr, MulticastRoute, MulticastRouteKey, MulticastRouteSource, @@ -33,12 +32,30 @@ fn origin_to_static_only(origin: Option) -> Option { } } -pub async fn mrib_status_imported( +pub async fn get_mrib_imported( rqctx: RequestContext>, - query: dropshot::Query, + 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), @@ -47,98 +64,42 @@ pub async fn mrib_status_imported( Ok(HttpResponseOk(routes)) } -pub async fn mrib_status_installed( +pub async fn get_mrib_selected( rqctx: RequestContext>, - query: dropshot::Query, + query: dropshot::Query, ) -> Result>, HttpError> { let ctx = rqctx.context(); let q = query.into_inner(); - 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 mrib_get_route( - rqctx: RequestContext>, - query: dropshot::Query, -) -> Result, HttpError> { - let ctx = rqctx.context(); - let q = query.into_inner(); - // Build the key from query params - let group = match q.group { - IpAddr::V4(v4) => MulticastAddr::try_from(v4).map_err(|e| { + // 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}"), ) - })?, - IpAddr::V6(v6) => MulticastAddr::try_from(v6).map_err(|e| { - HttpError::for_bad_request( + })?; + 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!("invalid group address: {e}"), + format!("route {key} not found in mrib_loc"), ) - })?, - }; - - let key = MulticastRouteKey { - source: q.source, - group, - vni: q.vni, - }; - - // Look up in `mrib_in` (all imported routes) - let route = ctx.db.get_mcast_route(&key).ok_or_else(|| { - HttpError::for_not_found(None, format!("route {key} not found")) - })?; - - Ok(HttpResponseOk(route)) -} - -pub async fn mrib_get_selected_route( - rqctx: RequestContext>, - query: dropshot::Query, -) -> Result, HttpError> { - let ctx = rqctx.context(); - let q = query.into_inner(); + })?; + return Ok(HttpResponseOk(vec![route])); + } - // Build the key from query params - let group = match q.group { - IpAddr::V4(v4) => MulticastAddr::try_from(v4).map_err(|e| { - HttpError::for_bad_request( - None, - format!("invalid group address: {e}"), - ) - })?, - IpAddr::V6(v6) => MulticastAddr::try_from(v6).map_err(|e| { - HttpError::for_bad_request( - None, - format!("invalid group address: {e}"), - ) - })?, - }; - - let key = MulticastRouteKey { - source: q.source, - group, - vni: q.vni, - }; - - // Look up in `mrib_loc` (installed/selected routes) - 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"), - ) - })?; - - Ok(HttpResponseOk(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 mrib_static_add( +pub async fn static_add_mcast_route( rqctx: RequestContext>, request: TypedBody, ) -> Result { @@ -174,7 +135,7 @@ pub async fn mrib_static_add( Ok(HttpResponseUpdatedNoContent()) } -pub async fn mrib_static_delete( +pub async fn static_remove_mcast_route( rqctx: RequestContext>, request: TypedBody, ) -> Result { @@ -186,7 +147,7 @@ pub async fn mrib_static_delete( Ok(HttpResponseDeleted()) } -pub async fn mrib_static_list( +pub async fn static_list_mcast_routes( rqctx: RequestContext>, ) -> Result>, HttpError> { let ctx = rqctx.context(); @@ -194,7 +155,7 @@ pub async fn mrib_static_list( Ok(HttpResponseOk(routes)) } -pub async fn mrib_get_rpf_rebuild_interval( +pub async fn read_mrib_rpf_rebuild_interval( rqctx: RequestContext>, ) -> Result, HttpError> { let ctx = rqctx.context(); @@ -207,7 +168,7 @@ pub async fn mrib_get_rpf_rebuild_interval( })) } -pub async fn mrib_set_rpf_rebuild_interval( +pub async fn update_mrib_rpf_rebuild_interval( rqctx: RequestContext>, request: TypedBody, ) -> Result { diff --git a/openapi/mg-admin/mg-admin-3.0.0-ababb5.json b/openapi/mg-admin/mg-admin-3.0.0-bb0197.json similarity index 96% rename from openapi/mg-admin/mg-admin-3.0.0-ababb5.json rename to openapi/mg-admin/mg-admin-3.0.0-bb0197.json index 0d40b08e..762713ac 100644 --- a/openapi/mg-admin/mg-admin-3.0.0-ababb5.json +++ b/openapi/mg-admin/mg-admin-3.0.0-bb0197.json @@ -1044,7 +1044,7 @@ "/mrib/config/rpf/rebuild-interval": { "get": { "summary": "Get the current RPF rebuild interval.", - "operationId": "mrib_get_rpf_rebuild_interval", + "operationId": "read_mrib_rpf_rebuild_interval", "responses": { "200": { "description": "successful operation", @@ -1066,7 +1066,7 @@ }, "post": { "summary": "Set the RPF rebuild interval.", - "operationId": "mrib_set_rpf_rebuild_interval", + "operationId": "update_mrib_rpf_rebuild_interval", "requestBody": { "content": { "application/json": { @@ -1090,25 +1090,42 @@ } } }, - "/mrib/route": { + "/mrib/status/imported": { "get": { - "summary": "Get a specific multicast route by key.", - "operationId": "mrib_get_route", + "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.", - "required": true, + "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 ASM (*,G)).", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", "schema": { "nullable": true, "type": "string", @@ -1118,7 +1135,7 @@ { "in": "query", "name": "vni", - "description": "VNI (defaults to 77 for fleet-scoped multicast).", + "description": "VNI (defaults to 77 for fleet-scoped multicast). Only used when `group` is set.", "schema": { "type": "integer", "format": "uint32", @@ -1132,7 +1149,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MulticastRoute" + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } } } } @@ -1146,26 +1167,42 @@ } } }, - "/mrib/route/installed": { + "/mrib/status/selected": { "get": { - "summary": "Get a specific installed multicast route by key (`mrib_loc`).", - "description": "Same query parameters as `mrib_get_route`, but returns the RPF-selected route from the installed table. Useful for verifying RPF neighbor and dataplane eligibility.", - "operationId": "mrib_get_selected_route", + "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.", - "required": true, + "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 ASM (*,G)).", + "description": "Source address (`None` for (*,G) routes). Only used when `group` is set.", "schema": { "nullable": true, "type": "string", @@ -1175,7 +1212,7 @@ { "in": "query", "name": "vni", - "description": "VNI (defaults to 77 for fleet-scoped multicast).", + "description": "VNI (defaults to 77 for fleet-scoped multicast). Only used when `group` is set.", "schema": { "type": "integer", "format": "uint32", @@ -1189,7 +1226,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MulticastRoute" + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } } } } @@ -1203,21 +1244,16 @@ } } }, - "/mrib/static/route": { + "/rib/config/bestpath/fanout": { "get": { - "summary": "List all static multicast routes.", - "operationId": "mrib_static_list", + "operationId": "read_rib_bestpath_fanout", "responses": { "200": { "description": "successful operation", "content": { "application/json": { "schema": { - "title": "Array_of_MulticastRoute", - "type": "array", - "items": { - "$ref": "#/components/schemas/MulticastRoute" - } + "$ref": "#/components/schemas/BestpathFanoutResponse" } } } @@ -1230,15 +1266,13 @@ } } }, - "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": "mrib_static_add", + "post": { + "operationId": "update_rib_bestpath_fanout", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MribAddStaticRequest" + "$ref": "#/components/schemas/BestpathFanoutRequest" } } }, @@ -1255,53 +1289,26 @@ "$ref": "#/components/responses/Error" } } - }, - "delete": { - "summary": "Delete static multicast routes.", - "description": "This endpoint is intended for Nexus RPW use. Operators should configure multicast group membership through the Oxide API.", - "operationId": "mrib_static_delete", - "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" - } - } } }, - "/mrib/status/imported": { + "/rib/status/imported": { "get": { - "summary": "Get all imported multicast routes (`mrib_in`).", - "operationId": "mrib_status_imported", + "operationId": "get_rib_imported", "parameters": [ { "in": "query", "name": "address_family", - "description": "Filter by address family (`None` returns all).", + "description": "Filter by address family (None means all families)", "schema": { "$ref": "#/components/schemas/AddressFamily" } }, { "in": "query", - "name": "route_origin", - "description": "Filter by route origin, i.e. \"Static\" or \"Dynamic\" (`None` returns all).", + "name": "protocol", + "description": "Filter by protocol (optional)", "schema": { - "$ref": "#/components/schemas/RouteOriginFilter" + "$ref": "#/components/schemas/ProtocolFilter" } } ], @@ -1311,11 +1318,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_MulticastRoute", - "type": "array", - "items": { - "$ref": "#/components/schemas/MulticastRoute" - } + "$ref": "#/components/schemas/Rib" } } } @@ -1329,25 +1332,24 @@ } } }, - "/mrib/status/installed": { + "/rib/status/selected": { "get": { - "summary": "Get installed multicast routes (`mrib_loc`, RPF-validated).", - "operationId": "mrib_status_installed", + "operationId": "get_rib_selected", "parameters": [ { "in": "query", "name": "address_family", - "description": "Filter by address family (`None` returns all).", + "description": "Filter by address family (None means all families)", "schema": { "$ref": "#/components/schemas/AddressFamily" } }, { "in": "query", - "name": "route_origin", - "description": "Filter by route origin, i.e. \"Static\" or \"Dynamic\" (`None` returns all).", + "name": "protocol", + "description": "Filter by protocol (optional)", "schema": { - "$ref": "#/components/schemas/RouteOriginFilter" + "$ref": "#/components/schemas/ProtocolFilter" } } ], @@ -1357,11 +1359,7 @@ "content": { "application/json": { "schema": { - "title": "Array_of_MulticastRoute", - "type": "array", - "items": { - "$ref": "#/components/schemas/MulticastRoute" - } + "$ref": "#/components/schemas/Rib" } } } @@ -1375,16 +1373,21 @@ } } }, - "/rib/config/bestpath/fanout": { + "/static/mroute": { "get": { - "operationId": "read_rib_bestpath_fanout", + "summary": "List all static multicast routes.", + "operationId": "static_list_mcast_routes", "responses": { "200": { "description": "successful operation", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BestpathFanoutResponse" + "title": "Array_of_MulticastRoute", + "type": "array", + "items": { + "$ref": "#/components/schemas/MulticastRoute" + } } } } @@ -1397,13 +1400,15 @@ } } }, - "post": { - "operationId": "update_rib_bestpath_fanout", + "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/BestpathFanoutRequest" + "$ref": "#/components/schemas/MribAddStaticRequest" } } }, @@ -1420,80 +1425,24 @@ "$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" - } + }, + "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" } } }, - "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" - } - } - ], + "required": true + }, "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Rib" - } - } - } + "204": { + "description": "successful deletion" }, "4XX": { "$ref": "#/components/responses/Error" @@ -3271,37 +3220,6 @@ "interval_ms" ] }, - "MulticastAddr": { - "description": "A validated multicast group address (IPv4 or IPv6).\n\nThis type guarantees that the contained address is a routable multicast address. Construction is only possible through validated paths.", - "oneOf": [ - { - "type": "object", - "properties": { - "V4": { - "type": "string", - "format": "ipv4" - } - }, - "required": [ - "V4" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "V6": { - "type": "string", - "format": "ipv6" - } - }, - "required": [ - "V6" - ], - "additionalProperties": false - } - ] - }, "MulticastRoute": { "description": "Multicast route entry containing replication groups and metadata.", "type": "object", @@ -3334,7 +3252,7 @@ ] }, "underlay_group": { - "description": "Underlay multicast group address (ff04::X).\n\nAdmin-scoped IPv6 multicast address corresponding to the overlay multicast group. 1:1 mapped and always derived from the overlay multicast group in Omicron.", + "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" }, @@ -3348,7 +3266,7 @@ "uniqueItems": true }, "updated": { - "description": "Last updated timestamp.", + "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" } @@ -3363,25 +3281,78 @@ ] }, "MulticastRouteKey": { - "description": "Multicast route key: (Source, Group) pair for source-specific multicast, or (*, Group) for any-source multicast.", + "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.", - "allOf": [ - { - "$ref": "#/components/schemas/MulticastAddr" - } - ] + "type": "string", + "format": "ipv4" }, "source": { "nullable": true, - "description": "Source address (None for any-source multicast (*,G)).", + "description": "Source address (`None` for (*,G) routes).", "type": "string", - "format": "ip" + "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) - defaults to 77 for fleet-wide multicast, but allows future per-VPC multicast routing.", + "description": "VNI (Virtual Network Identifier).", "default": 77, "type": "integer", "format": "uint32", @@ -4292,7 +4263,7 @@ ] }, "underlay_group": { - "description": "Underlay multicast group address (ff04::X).\n\nAdmin-scoped IPv6 multicast address corresponding to the overlay multicast group. 1:1 mapped and always derived from the overlay multicast group in Omicron.", + "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" }, diff --git a/openapi/mg-admin/mg-admin-latest.json b/openapi/mg-admin/mg-admin-latest.json index 06320092..438b2393 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-3.0.0-ababb5.json \ No newline at end of file +mg-admin-3.0.0-bb0197.json \ No newline at end of file diff --git a/rdb/src/db.rs b/rdb/src/db.rs index a02b3c62..4e2cf965 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -1481,7 +1481,7 @@ impl Db { /// 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 { + let Some(source) = key.source() else { self.mrib.promote_any_source(key); return; }; @@ -1530,7 +1530,7 @@ impl Db { .get_source_specific_keys() .into_iter() .filter_map(|key| { - let source = key.source?; + let source = key.source()?; // Targeted revalidation (skip routes not affected) if let Some(ref evt) = event && !evt.matches_source(source) @@ -1790,16 +1790,18 @@ impl Reaper { #[cfg(test)] mod test { use crate::{ - AddressFamily, DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, - Prefix6, StaticRouteKey, + AddressFamily, DEFAULT_MULTICAST_VNI, DEFAULT_RIB_PRIORITY_STATIC, + Path, Prefix, Prefix4, Prefix6, StaticRouteKey, db::Db, - test::{TestDb, mcast_v4, mcast_v6}, + test::{TEST_WAIT_ITERATIONS, TestDb}, types::{ - MulticastAddr, MulticastRoute, MulticastRouteKey, - MulticastRouteSource, PrefixDbKey, + 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; @@ -2091,7 +2093,6 @@ mod test { nexthop: IpAddr, group: MulticastAddr, ) { - let timeout = crate::test::TEST_TIMEOUT; let srk = StaticRouteKey { prefix: prefix.into(), nexthop, @@ -2100,7 +2101,12 @@ mod test { }; db.add_static_routes(&[srk]).unwrap(); - let key = MulticastRouteKey::source_specific(s_ip, group); + let key = MulticastRouteKey::new( + Some(s_ip), + group, + DEFAULT_MULTICAST_VNI, + ) + .expect("AF match"); let route = MulticastRoute::new( key, TEST_UNDERLAY, @@ -2109,10 +2115,11 @@ mod test { db.add_static_mcast_routes(&[route]).unwrap(); // Initially should be selected - crate::test::wait_for( - || db.get_selected_mcast_route(&key).is_some(), - timeout, - "(S,G) was not selected initially", + 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 @@ -2126,20 +2133,22 @@ mod test { // Remove unicast route; MRIB should be de-selected db.remove_static_routes(&[srk]).unwrap(); - crate::test::wait_for( - || db.get_selected_mcast_route(&key).is_none(), - timeout, - "(S,G) remained selected after unicast route removed", + 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 - crate::test::wait_for( - || db.get_selected_mcast_route(&key).is_some(), - timeout, - "(S,G) not re-selected after unicast route restored", + 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 @@ -2159,7 +2168,7 @@ mod test { IpAddr::V4(Ipv4Addr::new(192, 0, 2, 10)), "192.0.2.0/24".parse::().unwrap(), IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)), - mcast_v4(225, 1, 1, 1), + MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"), ); // IPv6 @@ -2168,7 +2177,8 @@ mod test { 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)), - mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), ); } @@ -2185,7 +2195,8 @@ mod test { // Case: (*,G) with ASM address goes to `mrib_loc` immediately // (no unicast route needed) - let asm_group = mcast_v4(225, 5, 5, 5); + 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, @@ -2196,10 +2207,11 @@ mod test { db.add_static_mcast_routes(&[star_g_route]).unwrap(); // (*,G) should be in both `mrib_in` AND `mrib_loc` immediately - crate::test::wait_for( - || db.get_selected_mcast_route(&star_g_key).is_some(), - crate::test::TEST_TIMEOUT, - "(*,G) should be in 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(), @@ -2207,9 +2219,10 @@ mod test { ); // Case: (S,G) with SSM address (232.x) - requires unicast route - let ssm_group = mcast_v4(232, 1, 1, 1); // SSM range - let source = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 100)); - let sg_key = MulticastRouteKey::source_specific(source, ssm_group); + 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, @@ -2237,14 +2250,17 @@ mod test { }; db.add_static_routes(&[srk]).unwrap(); - crate::test::wait_for( - || db.get_selected_mcast_route(&sg_key).is_some(), - crate::test::TEST_TIMEOUT, - "(S,G) should be selected after adding unicast route", + 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 = mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 0x5555]); + 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, @@ -2254,18 +2270,21 @@ mod test { db.add_static_mcast_routes(&[v6_star_g_route]).unwrap(); - crate::test::wait_for( - || db.get_selected_mcast_route(&v6_star_g_key).is_some(), - crate::test::TEST_TIMEOUT, - "IPv6 (*,G) should be selected immediately", + 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 = mcast_v6([0xff3e, 0, 0, 0, 0, 0, 0, 0x1234]); - let v6_source = - IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0x100)); + 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_source, v6_ssm_group); + MulticastRouteKey::source_specific_v6(v6_source, v6_ssm_group); let v6_sg_route = MulticastRoute::new( v6_sg_key, TEST_UNDERLAY, @@ -2294,10 +2313,11 @@ mod test { }; db.add_static_routes(&[v6_srk]).unwrap(); - crate::test::wait_for( - || db.get_selected_mcast_route(&v6_sg_key).is_some(), - crate::test::TEST_TIMEOUT, - "IPv6 (S,G) should be selected after adding unicast route", + 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 @@ -2316,7 +2336,7 @@ mod test { let db_path = "/tmp/mrib_persist_test.db"; let _ = std::fs::remove_dir_all(db_path); - let group = mcast_v4(225, 2, 2, 2); + let group = MulticastAddr::new_v4(225, 2, 2, 2).expect("valid mcast"); let key = MulticastRouteKey::any_source(group); let route = MulticastRoute::new( key, diff --git a/rdb/src/mrib/mod.rs b/rdb/src/mrib/mod.rs index 607f2196..b3e2ac27 100644 --- a/rdb/src/mrib/mod.rs +++ b/rdb/src/mrib/mod.rs @@ -151,10 +151,10 @@ impl Mrib { let af_match = match af { None => true, Some(AddressFamily::Ipv4) => { - matches!(route.key.group, MulticastAddr::V4(_)) + matches!(route.key.group(), MulticastAddr::V4(_)) } Some(AddressFamily::Ipv6) => { - matches!(route.key.group, MulticastAddr::V6(_)) + matches!(route.key.group(), MulticastAddr::V6(_)) } }; // Origin filter @@ -423,7 +423,7 @@ impl Mrib { ) -> Vec { lock!(self.mrib_in) .values() - .filter(|route| &route.key.group == group) + .filter(|route| &route.key.group() == group) .cloned() .collect() } @@ -435,7 +435,7 @@ impl Mrib { ) -> Vec { lock!(self.mrib_in) .values() - .filter(|route| route.key.source.as_ref() == Some(source)) + .filter(|route| route.key.source().as_ref() == Some(source)) .cloned() .collect() } @@ -444,7 +444,7 @@ impl Mrib { pub fn get_any_source_routes(&self) -> Vec { lock!(self.mrib_in) .values() - .filter(|route| route.key.source.is_none()) + .filter(|route| route.key.source().is_none()) .cloned() .collect() } @@ -453,7 +453,7 @@ impl Mrib { pub fn get_source_specific_keys(&self) -> Vec { lock!(self.mrib_in) .keys() - .filter(|key| key.source.is_some()) + .filter(|key| key.source().is_some()) .copied() .collect() } @@ -523,7 +523,7 @@ mod test { use mg_common::log::*; - use crate::test::{mcast_v4, mcast_v6}; + 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); @@ -534,7 +534,7 @@ mod test { let mrib = Mrib::new(log); // Test ASM route (*,G) - let group = mcast_v4(225, 1, 1, 1); + let group = MulticastAddr::new_v4(225, 1, 1, 1).expect("valid mcast"); let key = MulticastRouteKey::any_source(group); let route = MulticastRoute::new( key, @@ -546,8 +546,10 @@ mod test { assert!(mrib.get_route(&key).is_some()); // Test source-specific multicast route (S,G) - let source = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); - let key_sg = MulticastRouteKey::source_specific(source, group); + 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, @@ -592,7 +594,7 @@ mod test { mrib.watch("test-watcher".to_string(), tx); // Add a route and verify notification - let group = mcast_v4(225, 3, 3, 3); + let group = MulticastAddr::new_v4(225, 3, 3, 3).expect("valid mcast"); let key = MulticastRouteKey::any_source(group); let route = MulticastRoute::new( key, @@ -621,7 +623,7 @@ mod test { let mrib = Mrib::new(log); // Add a (*,G) route to mrib_in only - let group = mcast_v4(225, 4, 4, 4); + let group = MulticastAddr::new_v4(225, 4, 4, 4).expect("valid mcast"); let key = MulticastRouteKey::any_source(group); let route = MulticastRoute::new( key, @@ -660,7 +662,8 @@ mod test { let mrib = Mrib::new(log); // IPv6 ASM route (*,G) - let group = mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]); + 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, @@ -672,8 +675,11 @@ mod test { assert!(mrib.get_route(&key).is_some()); // IPv6 source-specific multicast route (S,G) - let source = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)); - let key_sg = MulticastRouteKey::source_specific(source, group); + 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, diff --git a/rdb/src/mrib/rpf.rs b/rdb/src/mrib/rpf.rs index e8b655a1..29928d7f 100644 --- a/rdb/src/mrib/rpf.rs +++ b/rdb/src/mrib/rpf.rs @@ -691,7 +691,10 @@ mod tests { 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 @@ -794,10 +797,11 @@ mod tests { rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); // Wait for rebuild to complete - crate::test::wait_for( - || rpf_table.cache_v4.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v4 rebuild timed out", + 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 @@ -851,10 +855,11 @@ mod tests { // Rebuild poptrie and test again rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); - crate::test::wait_for( - || rpf_table.cache_v4.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v4 rebuild timed out", + 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 @@ -892,10 +897,11 @@ mod tests { // Rebuild poptrie rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); - crate::test::wait_for( - || rpf_table.cache_v4.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v4 rebuild timed out", + 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` @@ -947,10 +953,11 @@ mod tests { ); rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); - crate::test::wait_for( - || rpf_table.cache_v4.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v4 rebuild timed out", + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" ); // Same with poptrie @@ -1015,10 +1022,11 @@ mod tests { rpf_table.trigger_rebuild_v6(Arc::clone(&rib6_loc), None); // Wait for rebuild to complete - crate::test::wait_for( - || rpf_table.cache_v6.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v6 rebuild timed out", + 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 @@ -1083,10 +1091,11 @@ mod tests { // Test with poptrie too rpf_table.trigger_rebuild_v4(Arc::clone(&rib4_loc), None); - crate::test::wait_for( - || rpf_table.cache_v4.read().unwrap().is_some(), - crate::test::TEST_TIMEOUT, - "poptrie v4 rebuild timed out", + wait_for!( + rpf_table.cache_v4.read().unwrap().is_some(), + DEFAULT_INTERVAL_MS, + TEST_WAIT_ITERATIONS, + "poptrie v4 rebuild timed out" ); assert_eq!( diff --git a/rdb/src/test.rs b/rdb/src/test.rs index 46287b03..8bcf5de0 100644 --- a/rdb/src/test.rs +++ b/rdb/src/test.rs @@ -4,16 +4,13 @@ //! Test utilities for rdb tests. -use crate::types::{MulticastAddr, MulticastAddrV4, MulticastAddrV6}; use crate::{Db, error::Error}; use slog::Logger; -use std::net::{Ipv4Addr, Ipv6Addr}; use std::ops::{Deref, DerefMut}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; -/// Default timeout for waiting on async operations in tests. -pub const TEST_TIMEOUT: Duration = Duration::from_secs(5); +/// 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. @@ -142,52 +139,3 @@ pub fn get_test_db(test_name: &str, log: Logger) -> Result { let db = Db::new(&db_path, log)?; Ok(TestDb { db, path: db_path }) } - -/// Wait for a condition to become true, polling until timeout. -/// -/// This is useful for tests that need to wait for asynchronous operations -/// like background rebuilds or notifications to complete. -/// -/// This fn panics with the provided message if the condition doesn't become -/// `true` within the timeout. -pub fn wait_for(mut condition: F, timeout: Duration, msg: &str) -where - F: FnMut() -> bool, -{ - let start = std::time::Instant::now(); - while !condition() { - if start.elapsed() > timeout { - panic!("{msg}"); - } - std::thread::sleep(Duration::from_millis(10)); - } -} - -// Multicast address test helpers - -/// Create a validated IPv4 multicast address for tests. -/// -/// Uses addresses in globally-routable ranges (not admin-scoped). -/// Panics if the address is not valid multicast. -pub fn mcast_v4(a: u8, b: u8, c: u8, d: u8) -> MulticastAddr { - MulticastAddr::V4(MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)).unwrap()) -} - -/// Create a validated IPv6 multicast address for tests. -/// -/// Panics if the address is not valid multicast. -pub fn mcast_v6(segments: [u16; 8]) -> MulticastAddr { - MulticastAddr::V6( - MulticastAddrV6::new(Ipv6Addr::new( - segments[0], - segments[1], - segments[2], - segments[3], - segments[4], - segments[5], - segments[6], - segments[7], - )) - .unwrap(), - ) -} diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 3f54baa8..7df79fc5 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -6,13 +6,12 @@ use crate::error::Error; use anyhow::Result; use chrono::{DateTime, Utc}; use omicron_common::address::{ - IPV4_ADMIN_SCOPED_MULTICAST_SUBNET, IPV4_GLOP_MULTICAST_SUBNET, - IPV4_LINK_LOCAL_MULTICAST_SUBNET, IPV4_MULTICAST_RANGE, - IPV4_SPECIFIC_RESERVED_MULTICAST_ADDRS, IPV4_SSM_SUBNET, + 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; @@ -483,14 +482,12 @@ impl Display for PrefixChangeNotification { // ============================================================================ /// Default VNI for fleet-wide multicast routing. -/// -/// Should match `omicron_common::api::external::Vni::DEFAULT_MULTICAST_VNI`. -pub const DEFAULT_MULTICAST_VNI: u32 = 77; +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, GLOP, admin-scoped, or otherwise reserved). +/// (not link-local). #[derive( Debug, Copy, @@ -518,7 +515,7 @@ impl MulticastAddrV4 { ))); } - // Reject link-local multicast (224.0.0.0/24) - not routed + // 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 \ @@ -526,31 +523,6 @@ impl MulticastAddrV4 { ))); } - // Reject GLOP addresses (233.0.0.0/8) - AS-based allocation - if IPV4_GLOP_MULTICAST_SUBNET.contains(value) { - return Err(Error::Validation(format!( - "IPv4 address {value} is in GLOP range \ - ({IPV4_GLOP_MULTICAST_SUBNET}) \ - which is reserved for AS-based allocation" - ))); - } - - // Reject admin-scoped addresses (239.0.0.0/8) - locally administered - if IPV4_ADMIN_SCOPED_MULTICAST_SUBNET.contains(value) { - return Err(Error::Validation(format!( - "IPv4 address {value} is admin-scoped \ - ({IPV4_ADMIN_SCOPED_MULTICAST_SUBNET}) \ - which is not globally routable" - ))); - } - - // Reject specific reserved addresses - if IPV4_SPECIFIC_RESERVED_MULTICAST_ADDRS.contains(&value) { - return Err(Error::Validation(format!( - "IPv4 address {value} is reserved and not routable" - ))); - } - Ok(Self(value)) } @@ -612,7 +584,15 @@ impl MulticastAddrV6 { ))); } - // Reject interface-local multicast (ff01::/16) - not routed + // 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 \ @@ -620,7 +600,7 @@ impl MulticastAddrV6 { ))); } - // Reject link-local multicast (ff02::/16) - not routed + // 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 \ @@ -628,14 +608,6 @@ impl MulticastAddrV6 { ))); } - // 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" - ))); - } - Ok(Self(value)) } @@ -688,6 +660,25 @@ pub enum MulticastAddr { } 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 { @@ -745,8 +736,30 @@ impl TryFrom for MulticastAddr { } } -/// Multicast route key: (Source, Group) pair for source-specific multicast, -/// or (*, Group) for any-source multicast. +/// 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). + source: Option, + /// Multicast group address. + group: MulticastAddrV4, + /// VNI (Virtual Network Identifier). + #[serde(default = "default_multicast_vni")] + vni: u32, +} + +/// IPv6 multicast route key with type-enforced address family matching. #[derive( Debug, Copy, @@ -759,15 +772,36 @@ impl TryFrom for MulticastAddr { Deserialize, JsonSchema, )] -pub struct MulticastRouteKey { - /// Source address (None for any-source multicast (*,G)). - pub source: Option, +pub struct MulticastRouteKeyV6 { + /// Source address (`None` for (*,G) routes). + source: Option, /// Multicast group address. - pub group: MulticastAddr, - /// VNI (Virtual Network Identifier) - defaults to 77 for fleet-wide - /// multicast, but allows future per-VPC multicast routing. + group: MulticastAddrV6, + /// VNI (Virtual Network Identifier). #[serde(default = "default_multicast_vni")] - pub vni: u32, + 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 { @@ -776,51 +810,171 @@ const fn default_multicast_vni() -> u32 { impl fmt::Display for MulticastRouteKey { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - match self.source { - Some(src) => write!(f, "({},{})", src, self.group), - None => write!(f, "(*,{})", self.group), + 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 { - Self { - source: None, + 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 multicast route (S,G) with default VNI. - pub fn source_specific(source: IpAddr, group: MulticastAddr) -> Self { - Self { + /// 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 { - Self { - source: None, + 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 multicast route (S,G) with specified VNI. - pub fn source_specific_with_vni( - source: IpAddr, - group: MulticastAddr, + /// 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 { + 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, } } @@ -845,37 +999,39 @@ impl MulticastRouteKey { /// Validate the multicast route key. /// /// Checks: - /// - SSM groups (232.0.0.0/8, ff3x::/12) require a source address + /// - 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 - /// - Source address family must match group address family /// - 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 { + if self.vni() > MAX_VNI { return Err(Error::Validation(format!( "VNI {} exceeds maximum value {MAX_VNI}", - self.vni + self.vni() ))); } - // SSM (Source-Specific Multicast) groups require a source address - let is_ssm = match self.group { - MulticastAddr::V4(addr) => IPV4_SSM_SUBNET.contains(addr.ip()), - MulticastAddr::V6(addr) => IPV6_SSM_SUBNET.contains(addr.ip()), + // 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() { + if is_ssm && self.source().is_none() { return Err(Error::Validation(format!( "SSM group {} requires a source address", - self.group + self.group() ))); } // Validate source address if present - if let Some(source) = &self.source { - // Source must be unicast (not multicast or broadcast) - match source { - IpAddr::V4(addr) => { + 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" @@ -886,25 +1042,23 @@ impl MulticastRouteKey { "source address {addr} must be unicast, not broadcast" ))); } - // Address family must match group - if !matches!(self.group, MulticastAddr::V4(_)) { + if addr.is_loopback() { return Err(Error::Validation(format!( - "source address {addr} is IPv4 but group {} is IPv6", - self.group + "source address {addr} must not be loopback" ))); } } - IpAddr::V6(addr) => { + } + 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" ))); } - // Address family must match group - if !matches!(self.group, MulticastAddr::V6(_)) { + if addr.is_loopback() { return Err(Error::Validation(format!( - "source address {addr} is IPv6 but group {} is IPv4", - self.group + "source address {addr} must not be loopback" ))); } } @@ -941,8 +1095,8 @@ pub struct MulticastRoute { /// Last updated timestamp. /// /// Only updated when route fields change semantically (rpf_neighbor, - /// underlay_group, underlay_nexthops, source). Idempotent upserts with - /// identical values do not update this timestamp. + /// underlay_group, underlay_nexthops, source). An idempotent upsert with + /// an identical value does not update this timestamp. pub updated: DateTime, } @@ -1015,10 +1169,10 @@ impl MulticastRoute { ))); } // Address family must match group - if !matches!(self.key.group, MulticastAddr::V4(_)) { + if !matches!(self.key.group(), MulticastAddr::V4(_)) { return Err(Error::Validation(format!( "RPF neighbor {addr} is IPv4 but group {} is IPv6", - self.key.group + self.key.group() ))); } } @@ -1029,10 +1183,10 @@ impl MulticastRoute { ))); } // AF must match group - if !matches!(self.key.group, MulticastAddr::V6(_)) { + if !matches!(self.key.group(), MulticastAddr::V6(_)) { return Err(Error::Validation(format!( "RPF neighbor {addr} is IPv6 but group {} is IPv4", - self.key.group + self.key.group() ))); } } @@ -1097,7 +1251,6 @@ impl From for MribChangeNotification { #[cfg(test)] mod tests { use super::*; - use crate::test::{mcast_v4, mcast_v6}; // MulticastAddr validation tests @@ -1129,20 +1282,6 @@ mod tests { assert!(matches!(result, Err(Error::Validation(_)))); } - #[test] - fn multicast_addr_rejects_ipv4_glop() { - // 233.x.x.x is GLOP (AS-based allocation), not globally routable - let result = MulticastAddr::try_from(Ipv4Addr::new(233, 1, 2, 3)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - #[test] - fn multicast_addr_rejects_ipv4_admin_scoped() { - // 239.x.x.x is admin-scoped, not globally routable - let result = MulticastAddr::try_from(Ipv4Addr::new(239, 1, 2, 3)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - #[test] fn multicast_addr_accepts_valid_ipv6() { // ff0e::1 is global scope multicast @@ -1189,20 +1328,24 @@ mod tests { #[test] fn test_multicast_route_key_v4() { // (*,G) valid (using 225.x which is globally routable) - let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + ); assert!(key.validate().is_ok(), "(*,G) should be valid"); // (S,G) valid - let key = MulticastRouteKey::source_specific( - IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), - mcast_v4(225, 1, 2, 3), + let key = MulticastRouteKey::source_specific_v4( + Ipv4Addr::new(10, 0, 0, 1), + MulticastAddrV4::new(Ipv4Addr::new(225, 1, 2, 3)) + .expect("valid mcast"), ); assert!(key.validate().is_ok(), "(S,G) should be valid"); // Multicast source rejected - let key = MulticastRouteKey::source_specific( - IpAddr::V4(Ipv4Addr::new(225, 1, 1, 1)), // multicast as source - mcast_v4(225, 1, 2, 3), + let key = MulticastRouteKey::source_specific_v4( + Ipv4Addr::new(225, 1, 1, 1), // multicast as source + MulticastAddrV4::new(Ipv4Addr::new(225, 1, 2, 3)) + .expect("valid mcast"), ); assert!( matches!(key.validate(), Err(Error::Validation(_))), @@ -1210,16 +1353,19 @@ mod tests { ); // SSM (232.x.x.x) requires source - let key = MulticastRouteKey::any_source(mcast_v4(232, 1, 2, 3)); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v4(232, 1, 2, 3).expect("valid mcast"), + ); assert!( matches!(key.validate(), Err(Error::Validation(_))), "SSM without source should be rejected" ); // SSM with source is valid - let key = MulticastRouteKey::source_specific( - IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), - mcast_v4(232, 1, 2, 3), + let key = MulticastRouteKey::source_specific_v4( + Ipv4Addr::new(10, 0, 0, 1), + MulticastAddrV4::new(Ipv4Addr::new(232, 1, 2, 3)) + .expect("valid mcast"), ); assert!(key.validate().is_ok(), "SSM with source should be valid"); } @@ -1227,22 +1373,25 @@ mod tests { #[test] fn test_multicast_route_key_v6() { // (*,G) valid (ff0e = global scope) - let key = MulticastRouteKey::any_source(mcast_v6([ - 0xff0e, 0, 0, 0, 0, 0, 0, 1, - ])); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); assert!(key.validate().is_ok(), "(*,G) should be valid"); // (S,G) valid - let key = MulticastRouteKey::source_specific( - IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), - mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + let key = MulticastRouteKey::source_specific_v6( + Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), + MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid mcast"), ); assert!(key.validate().is_ok(), "(S,G) should be valid"); // Multicast source rejected - let key = MulticastRouteKey::source_specific( - IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)), // mcast - mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 2]), + let key = MulticastRouteKey::source_specific_v6( + Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1), // mcast + MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 2)) + .expect("valid mcast"), ); assert!( matches!(key.validate(), Err(Error::Validation(_))), @@ -1250,41 +1399,46 @@ mod tests { ); // SSM (ff3x::) requires source - let key = MulticastRouteKey::any_source(mcast_v6([ - 0xff3e, 0, 0, 0, 0, 0, 0, 1, - ])); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v6([0xff3e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); assert!( matches!(key.validate(), Err(Error::Validation(_))), "SSM without source should be rejected" ); // SSM with source is valid - let key = MulticastRouteKey::source_specific( - IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), - mcast_v6([0xff3e, 0, 0, 0, 0, 0, 0, 1]), + let key = MulticastRouteKey::source_specific_v6( + Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), + MulticastAddrV6::new(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 1)) + .expect("valid mcast"), ); assert!(key.validate().is_ok(), "SSM with source should be valid"); } #[test] fn test_multicast_route_key_af_mismatch() { - // IPv4 source with IPv6 group - let key = MulticastRouteKey::source_specific( - IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), - mcast_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]), + // IPv4 source with IPv6 group should be rejected at construction time + let result = MulticastRouteKey::new( + Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + DEFAULT_MULTICAST_VNI, ); assert!( - matches!(key.validate(), Err(Error::Validation(_))), + matches!(result, Err(Error::Validation(_))), "v4 source with v6 group should be rejected" ); - // IPv6 source with IPv4 group - let key = MulticastRouteKey::source_specific( - IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)), - mcast_v4(225, 1, 2, 3), + // IPv6 source with IPv4 group should be rejected at construction time + let result = MulticastRouteKey::new( + Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))), + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + DEFAULT_MULTICAST_VNI, ); assert!( - matches!(key.validate(), Err(Error::Validation(_))), + matches!(result, Err(Error::Validation(_))), "v6 source with v4 group should be rejected" ); } @@ -1292,22 +1446,20 @@ mod tests { #[test] fn test_multicast_route_key_vni() { // VNI too large - let key = MulticastRouteKey { - source: None, - group: mcast_v4(225, 1, 2, 3), - vni: 1 << 24, // MAX_VNI + 1 - }; + let key = MulticastRouteKey::any_source_with_vni( + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + 1 << 24, // MAX_VNI + 1 + ); assert!( matches!(key.validate(), Err(Error::Validation(_))), "VNI > 24 bits should be rejected" ); // Max VNI is valid - let key = MulticastRouteKey { - source: None, - group: mcast_v4(225, 1, 2, 3), - vni: (1 << 24) - 1, // MAX_VNI - }; + let key = MulticastRouteKey::any_source_with_vni( + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + (1 << 24) - 1, // MAX_VNI + ); assert!(key.validate().is_ok(), "max VNI should be valid"); } @@ -1319,7 +1471,9 @@ mod tests { #[test] fn test_multicast_route_v4() { // Without RPF neighbor - let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + ); let route = MulticastRoute::new( key, TEST_UNDERLAY, @@ -1358,9 +1512,10 @@ mod tests { #[test] fn test_multicast_route_v6() { // With valid unicast RPF neighbor - let key = MulticastRouteKey::any_source(mcast_v6([ - 0xff0e, 0, 0, 0, 0, 0, 0, 1, - ])); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); let mut route = MulticastRoute::new( key, TEST_UNDERLAY, @@ -1390,9 +1545,10 @@ mod tests { #[test] fn test_multicast_route_rpf_af_mismatch() { // IPv4 RPF with IPv6 group - let key = MulticastRouteKey::any_source(mcast_v6([ - 0xff0e, 0, 0, 0, 0, 0, 0, 1, - ])); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) + .expect("valid mcast"), + ); let mut route = MulticastRoute::new( key, TEST_UNDERLAY, @@ -1405,7 +1561,9 @@ mod tests { ); // IPv6 RPF with IPv4 group - let key = MulticastRouteKey::any_source(mcast_v4(225, 1, 2, 3)); + let key = MulticastRouteKey::any_source( + MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), + ); let mut route = MulticastRoute::new( key, TEST_UNDERLAY, From 945d5840e7866207173a2df358ca53532af0eec2 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 10 Dec 2025 01:06:30 +0000 Subject: [PATCH 3/7] proptest for types/validation --- rdb/src/proptest.rs | 928 +++++++++++++++++++++++++++++++++++++++++++- rdb/src/types.rs | 341 +--------------- 2 files changed, 933 insertions(+), 336 deletions(-) diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 858693aa..570d5a2f 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -8,7 +8,16 @@ //! 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_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, IPV6_SSM_SUBNET, +}; +use omicron_common::api::external::Vni; use proptest::prelude::*; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -322,3 +331,920 @@ proptest! { } } } + +// ============================================================================ +// Multicast address and route-key property tests and setup +// ============================================================================ + +// Strategy for generating IPv4 unicast addresses (non-multicast) +fn ipv4_unicast_strategy() -> impl Strategy { + any::().prop_filter_map("must be unicast", |bits| { + let addr = Ipv4Addr::from(bits); + if !addr.is_multicast() && !addr.is_broadcast() && !addr.is_loopback() { + Some(addr) + } else { + None + } + }) +} + +// Strategy for generating IPv6 unicast addresses (non-multicast) +fn ipv6_unicast_strategy() -> impl Strategy { + any::().prop_filter_map("must be unicast", |bits| { + let addr = Ipv6Addr::from(bits); + if !addr.is_multicast() && !addr.is_loopback() { + Some(addr) + } else { + None + } + }) +} + +// Strategy for generating IPv4 SSM addresses (232.0.0.0/8) +fn ipv4_ssm_strategy() -> impl Strategy { + (0u8..=255, 0u8..=255, 0u8..=255) + .prop_map(|(b, c, d)| Ipv4Addr::new(232, b, c, d)) +} + +// Strategy for generating IPv6 SSM addresses (ff3x::/32 with various scopes) +fn ipv6_ssm_strategy() -> impl Strategy { + // ff30::/12 covers all SSM scopes (ff30::, ff31::, ..., ff3f::) + (0x30u8..=0x3f, any::<[u16; 7]>()).prop_map(|(scope_nibble, segs)| { + let first_segment = 0xff00 | (scope_nibble as u16); + Ipv6Addr::new( + first_segment, + segs[0], + segs[1], + segs[2], + segs[3], + segs[4], + segs[5], + segs[6], + ) + }) +} + +// 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 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. + +/// Strategy for generating valid unicast IPv4 sources. +fn v4_unicast_source_strategy() -> impl Strategy { + any::().prop_filter_map("must be unicast", |bits| { + let addr = Ipv4Addr::from(bits); + if addr.is_multicast() || addr.is_broadcast() || addr.is_loopback() { + None + } else { + Some(addr) + } + }) +} + +/// Strategy for generating valid unicast IPv6 sources. +fn v6_unicast_source_strategy() -> impl Strategy { + any::().prop_filter_map("must be unicast", |bits| { + let addr = Ipv6Addr::from(bits); + if addr.is_multicast() || addr.is_loopback() { + None + } else { + Some(addr) + } + }) +} + +impl Arbitrary for MulticastAddrV4 { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // 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![ + // 224.0.1.0 - 224.255.255.255 (skip 224.0.0.x link-local) + (0u8..=255, 1u8..=255, any::()).prop_filter_map( + "224.0.1+ range", + |(b, c, d)| { + // 224.0.1+ is valid (224.0.0.x is link-local) + if b == 0 && c == 0 { + return None; + } + MulticastAddrV4::new(Ipv4Addr::new(224, b, c, d)).ok() + } + ), + // 225.x.x.x - 231.x.x.x (globally routable) + (225u8..=231, any::(), any::(), any::()).prop_map( + |(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("225-231 is valid multicast") + } + ), + // 232.x.x.x (SSM range) + (any::(), any::(), any::()).prop_map(|(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(232, b, c, d)) + .expect("232 is valid SSM") + }), + // 233.x.x.x - 238.x.x.x (GLOP, admin-scoped, etc.) + (233u8..=238, any::(), any::(), any::()).prop_map( + |(a, b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(a, b, c, d)) + .expect("233-238 is valid multicast") + } + ), + // 239.x.x.x (admin-scoped) + (any::(), any::(), any::()).prop_map(|(b, c, d)| { + MulticastAddrV4::new(Ipv4Addr::new(239, b, c, d)) + .expect("239 is valid admin-scoped") + }), + ] + .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 = + 0xff00 | ((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() + } +} + +impl Arbitrary for MulticastRouteKey { + type Parameters = (); + type Strategy = BoxedStrategy; + + fn arbitrary_with(_: Self::Parameters) -> Self::Strategy { + // Split into distinct cases to avoid complex filtering: + // 1. ASM (*,G) - no source required + // 2. ASM (S,G) - optional source + // 3. SSM (S,G) - source required + let vni_strategy = 0u32..=Vni::MAX_VNI; + + prop_oneof![ + // V4 ASM (*,G): any non-SSM group, no source + (any::(), vni_strategy.clone()).prop_filter_map( + "ASM v4 (*,G)", + |(grp, vni)| { + if IPV4_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: None, + group: grp, + vni, + })) + } + ), + // V4 ASM (S,G): any non-SSM group with unicast source + ( + v4_unicast_source_strategy(), + any::(), + vni_strategy.clone() + ) + .prop_filter_map( + "ASM v4 (S,G)", + |(src, grp, vni)| { + if IPV4_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + })) + } + ), + // V4 SSM (S,G): SSM group requires source + ( + v4_unicast_source_strategy(), + any::(), + vni_strategy.clone() + ) + .prop_filter_map( + "SSM v4 (S,G)", + |(src, grp, vni)| { + if !IPV4_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { + source: Some(src), + group: grp, + vni, + })) + } + ), + // V6 ASM (*,G): any non-SSM group, no source + (any::(), vni_strategy.clone()).prop_filter_map( + "ASM v6 (*,G)", + |(grp, vni)| { + if IPV6_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: None, + group: grp, + vni, + })) + } + ), + // V6 ASM (S,G): any non-SSM group with unicast source + ( + v6_unicast_source_strategy(), + any::(), + vni_strategy.clone() + ) + .prop_filter_map( + "ASM v6 (S,G)", + |(src, grp, vni)| { + if IPV6_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(MulticastRouteKey::V6(MulticastRouteKeyV6 { + source: Some(src), + group: grp, + vni, + })) + } + ), + // V6 SSM (S,G): SSM group requires source + ( + v6_unicast_source_strategy(), + any::(), + vni_strategy + ) + .prop_filter_map( + "SSM v6 (S,G)", + |(src, grp, vni)| { + if !IPV6_SSM_SUBNET.contains(grp.ip()) { + return None; + } + Some(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_eq!(addr.ip().is_multicast(), true); + } + + /// Property: Arbitrary `MulticastAddrV6` always validates + #[test] + fn prop_multicast_addr_v6_arbitrary_valid(addr in any::()) { + // Arbitrary impl only generates valid addresses + prop_assert_eq!(addr.ip().is_multicast(), true); + } + + /// 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..=255) { + let addr = Ipv4Addr::new(224, 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 link_local = Ipv6Addr::new( + 0xff02, 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 if_local = Ipv6Addr::new( + 0xff01, 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 any::()) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::()) { + prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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(addr in ipv4_ssm_strategy()) { + let group = MulticastAddrV4::new(addr).expect("valid ssm"); + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {addr} should require source" + ); + } + + /// Property: SSM without source fails validation (IPv6) + #[test] + fn prop_route_key_ssm_requires_source_v6(addr in ipv6_ssm_strategy()) { + // Filter to ensure we have a valid SSM address + prop_assume!(IPV6_SSM_SUBNET.contains(addr)); + if let Ok(group) = MulticastAddrV6::new(addr) { + let key = MulticastRouteKey::any_source(group.into()); + prop_assert!( + key.validate().is_err(), + "SSM (*,G) with {addr} 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(), + addr in ipv4_ssm_strategy(), + ) { + let group = MulticastAddrV4::new(addr).expect("valid ssm"); + let key = MulticastRouteKey::source_specific_v4(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{addr} 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(), + addr in ipv6_ssm_strategy(), + ) { + prop_assume!(IPV6_SSM_SUBNET.contains(addr)); + if let Ok(group) = MulticastAddrV6::new(addr) { + let key = MulticastRouteKey::source_specific_v6(src, group); + prop_assert!( + key.validate().is_ok(), + "SSM (S,G) with {src},{addr} 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: 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 any::(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + ) { + prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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 any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + underlay in any::(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + // Only test if underlay is not already admin-local + prop_assume!(underlay.ip().segments()[0] != IPV6_ADMIN_SCOPED_MULTICAST_PREFIX); + 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 any::(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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 any::(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + rpf in any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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 any::(), + rpf in ipv4_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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 any::(), + rpf in ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + nexthop in routable_ipv6_unicast_strategy(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + nexthop in any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + segs in any::<[u16; 7]>(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + // 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 any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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 any::(), + underlay in admin_local_multicast_strategy(), + ) { + prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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/types.rs b/rdb/src/types.rs index 7df79fc5..248232e4 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -751,12 +751,12 @@ impl TryFrom for MulticastAddr { )] pub struct MulticastRouteKeyV4 { /// Source address (`None` for (*,G) routes). - source: Option, + pub(crate) source: Option, /// Multicast group address. - group: MulticastAddrV4, + pub(crate) group: MulticastAddrV4, /// VNI (Virtual Network Identifier). #[serde(default = "default_multicast_vni")] - vni: u32, + pub(crate) vni: u32, } /// IPv6 multicast route key with type-enforced address family matching. @@ -774,12 +774,12 @@ pub struct MulticastRouteKeyV4 { )] pub struct MulticastRouteKeyV6 { /// Source address (`None` for (*,G) routes). - source: Option, + pub(crate) source: Option, /// Multicast group address. - group: MulticastAddrV6, + pub(crate) group: MulticastAddrV6, /// VNI (Virtual Network Identifier). #[serde(default = "default_multicast_vni")] - vni: u32, + pub(crate) vni: u32, } /// Multicast route key: (Source, Group) pair for source-specific multicast, @@ -1248,332 +1248,3 @@ impl From for MribChangeNotification { } } -#[cfg(test)] -mod tests { - use super::*; - - // MulticastAddr validation tests - - #[test] - fn multicast_addr_accepts_valid_ipv4() { - // 225.x.x.x is globally-routable multicast (not GLOP or admin-scoped) - let result = MulticastAddr::try_from(Ipv4Addr::new(225, 1, 2, 3)); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), MulticastAddr::V4(_))); - } - - #[test] - fn multicast_addr_accepts_ipv4_ssm() { - // 232.x.x.x is SSM range - let result = MulticastAddr::try_from(Ipv4Addr::new(232, 1, 2, 3)); - assert!(result.is_ok()); - } - - #[test] - fn multicast_addr_rejects_ipv4_unicast() { - let result = MulticastAddr::try_from(Ipv4Addr::new(10, 0, 0, 1)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - #[test] - fn multicast_addr_rejects_ipv4_link_local() { - // 224.0.0.x is link-local, not routable - let result = MulticastAddr::try_from(Ipv4Addr::new(224, 0, 0, 1)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - #[test] - fn multicast_addr_accepts_valid_ipv6() { - // ff0e::1 is global scope multicast - let result = - MulticastAddr::try_from(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), MulticastAddr::V6(_))); - } - - #[test] - fn multicast_addr_accepts_ipv6_ssm() { - // ff3e::1 is SSM range - let result = - MulticastAddr::try_from(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 1)); - assert!(result.is_ok()); - } - - #[test] - fn multicast_addr_rejects_ipv6_unicast() { - let result = MulticastAddr::try_from(Ipv6Addr::new( - 0x2001, 0xdb8, 0, 0, 0, 0, 0, 1, - )); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - #[test] - fn multicast_addr_rejects_ipv6_link_local() { - // ff02::1 is link-local, not routable - let result = - MulticastAddr::try_from(Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - #[test] - fn multicast_addr_rejects_ipv6_interface_local() { - // ff01::1 is interface-local, not routable - let result = - MulticastAddr::try_from(Ipv6Addr::new(0xff01, 0, 0, 0, 0, 0, 0, 1)); - assert!(matches!(result, Err(Error::Validation(_)))); - } - - // MulticastRouteKey validation tests - - #[test] - fn test_multicast_route_key_v4() { - // (*,G) valid (using 225.x which is globally routable) - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "(*,G) should be valid"); - - // (S,G) valid - let key = MulticastRouteKey::source_specific_v4( - Ipv4Addr::new(10, 0, 0, 1), - MulticastAddrV4::new(Ipv4Addr::new(225, 1, 2, 3)) - .expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "(S,G) should be valid"); - - // Multicast source rejected - let key = MulticastRouteKey::source_specific_v4( - Ipv4Addr::new(225, 1, 1, 1), // multicast as source - MulticastAddrV4::new(Ipv4Addr::new(225, 1, 2, 3)) - .expect("valid mcast"), - ); - assert!( - matches!(key.validate(), Err(Error::Validation(_))), - "multicast source should be rejected" - ); - - // SSM (232.x.x.x) requires source - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v4(232, 1, 2, 3).expect("valid mcast"), - ); - assert!( - matches!(key.validate(), Err(Error::Validation(_))), - "SSM without source should be rejected" - ); - - // SSM with source is valid - let key = MulticastRouteKey::source_specific_v4( - Ipv4Addr::new(10, 0, 0, 1), - MulticastAddrV4::new(Ipv4Addr::new(232, 1, 2, 3)) - .expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "SSM with source should be valid"); - } - - #[test] - fn test_multicast_route_key_v6() { - // (*,G) valid (ff0e = global scope) - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) - .expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "(*,G) should be valid"); - - // (S,G) valid - let key = MulticastRouteKey::source_specific_v6( - Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), - MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1)) - .expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "(S,G) should be valid"); - - // Multicast source rejected - let key = MulticastRouteKey::source_specific_v6( - Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 1), // mcast - MulticastAddrV6::new(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 2)) - .expect("valid mcast"), - ); - assert!( - matches!(key.validate(), Err(Error::Validation(_))), - "multicast source should be rejected" - ); - - // SSM (ff3x::) requires source - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v6([0xff3e, 0, 0, 0, 0, 0, 0, 1]) - .expect("valid mcast"), - ); - assert!( - matches!(key.validate(), Err(Error::Validation(_))), - "SSM without source should be rejected" - ); - - // SSM with source is valid - let key = MulticastRouteKey::source_specific_v6( - Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), - MulticastAddrV6::new(Ipv6Addr::new(0xff3e, 0, 0, 0, 0, 0, 0, 1)) - .expect("valid mcast"), - ); - assert!(key.validate().is_ok(), "SSM with source should be valid"); - } - - #[test] - fn test_multicast_route_key_af_mismatch() { - // IPv4 source with IPv6 group should be rejected at construction time - let result = MulticastRouteKey::new( - Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), - MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) - .expect("valid mcast"), - DEFAULT_MULTICAST_VNI, - ); - assert!( - matches!(result, Err(Error::Validation(_))), - "v4 source with v6 group should be rejected" - ); - - // IPv6 source with IPv4 group should be rejected at construction time - let result = MulticastRouteKey::new( - Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))), - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - DEFAULT_MULTICAST_VNI, - ); - assert!( - matches!(result, Err(Error::Validation(_))), - "v6 source with v4 group should be rejected" - ); - } - - #[test] - fn test_multicast_route_key_vni() { - // VNI too large - let key = MulticastRouteKey::any_source_with_vni( - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - 1 << 24, // MAX_VNI + 1 - ); - assert!( - matches!(key.validate(), Err(Error::Validation(_))), - "VNI > 24 bits should be rejected" - ); - - // Max VNI is valid - let key = MulticastRouteKey::any_source_with_vni( - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - (1 << 24) - 1, // MAX_VNI - ); - assert!(key.validate().is_ok(), "max VNI should be valid"); - } - - // MulticastRoute validation tests - - // Valid admin-scoped underlay address for tests - const TEST_UNDERLAY: Ipv6Addr = Ipv6Addr::new(0xff04, 0, 0, 0, 0, 0, 0, 1); - - #[test] - fn test_multicast_route_v4() { - // Without RPF neighbor - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - ); - let route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - assert!( - route.validate().is_ok(), - "route without RPF should be valid" - ); - - // With valid unicast RPF neighbor - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); - assert!( - route.validate().is_ok(), - "route with unicast RPF should be valid" - ); - - // With multicast RPF neighbor (invalid) - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(225, 1, 1, 1))); - assert!( - matches!(route.validate(), Err(Error::Validation(_))), - "multicast RPF neighbor should be rejected" - ); - } - - #[test] - fn test_multicast_route_v6() { - // With valid unicast RPF neighbor - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) - .expect("valid mcast"), - ); - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = - Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); - assert!( - route.validate().is_ok(), - "route with unicast RPF should be valid" - ); - - // With multicast RPF neighbor (invalid) - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = - Some(IpAddr::V6(Ipv6Addr::new(0xff0e, 0, 0, 0, 0, 0, 0, 2))); - assert!( - matches!(route.validate(), Err(Error::Validation(_))), - "multicast RPF neighbor should be rejected" - ); - } - - #[test] - fn test_multicast_route_rpf_af_mismatch() { - // IPv4 RPF with IPv6 group - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v6([0xff0e, 0, 0, 0, 0, 0, 0, 1]) - .expect("valid mcast"), - ); - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))); - assert!( - matches!(route.validate(), Err(Error::Validation(_))), - "v4 RPF with v6 group should be rejected" - ); - - // IPv6 RPF with IPv4 group - let key = MulticastRouteKey::any_source( - MulticastAddr::new_v4(225, 1, 2, 3).expect("valid mcast"), - ); - let mut route = MulticastRoute::new( - key, - TEST_UNDERLAY, - MulticastRouteSource::Static, - ); - route.rpf_neighbor = - Some(IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1))); - assert!( - matches!(route.validate(), Err(Error::Validation(_))), - "v6 RPF with v4 group should be rejected" - ); - } -} From 77cf0ff781e3ae63233587d24e1717a2be930fd7 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 10 Dec 2025 01:11:54 +0000 Subject: [PATCH 4/7] .. --- rdb/src/types.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 248232e4..59891dc9 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -1247,4 +1247,3 @@ impl From for MribChangeNotification { } } } - From a35e14d016ad483e103df3030edc2edd5b11fe2d Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Wed, 10 Dec 2025 03:54:20 +0000 Subject: [PATCH 5/7] [fix] proptests and redesign strategies --- rdb/src/proptest.rs | 592 +++++++++++++++++++++++++------------------- 1 file changed, 332 insertions(+), 260 deletions(-) diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index 570d5a2f..bfe672a3 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -15,7 +15,9 @@ use crate::types::{ StaticRouteKey, }; use omicron_common::address::{ - IPV4_SSM_SUBNET, IPV6_ADMIN_SCOPED_MULTICAST_PREFIX, IPV6_SSM_SUBNET, + 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::*; @@ -336,54 +338,34 @@ proptest! { // Multicast address and route-key property tests and setup // ============================================================================ -// Strategy for generating IPv4 unicast addresses (non-multicast) +// 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 { - any::().prop_filter_map("must be unicast", |bits| { - let addr = Ipv4Addr::from(bits); - if !addr.is_multicast() && !addr.is_broadcast() && !addr.is_loopback() { - Some(addr) - } else { - None - } - }) + 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) +// 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 { - any::().prop_filter_map("must be unicast", |bits| { + // 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() { - Some(addr) - } else { + if addr.is_multicast() || addr.is_loopback() { None + } else { + Some(addr) } }) } -// Strategy for generating IPv4 SSM addresses (232.0.0.0/8) -fn ipv4_ssm_strategy() -> impl Strategy { - (0u8..=255, 0u8..=255, 0u8..=255) - .prop_map(|(b, c, d)| Ipv4Addr::new(232, b, c, d)) -} - -// Strategy for generating IPv6 SSM addresses (ff3x::/32 with various scopes) -fn ipv6_ssm_strategy() -> impl Strategy { - // ff30::/12 covers all SSM scopes (ff30::, ff31::, ..., ff3f::) - (0x30u8..=0x3f, any::<[u16; 7]>()).prop_map(|(scope_nibble, segs)| { - let first_segment = 0xff00 | (scope_nibble as u16); - Ipv6Addr::new( - first_segment, - segs[0], - segs[1], - segs[2], - segs[3], - segs[4], - segs[5], - segs[6], - ) - }) -} - // Strategy for generating valid VNIs (0 to Vni::MAX_VNI) fn valid_vni_strategy() -> impl Strategy { 0u32..=Vni::MAX_VNI @@ -412,6 +394,28 @@ fn admin_local_multicast_strategy() -> impl Strategy { }) } +// 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| { @@ -433,75 +437,69 @@ fn routable_ipv6_unicast_strategy() -> impl Strategy { // ============================================================================ // // These allow using `any::()` etc. in property tests, -// generating only valid instances of each type. - -/// Strategy for generating valid unicast IPv4 sources. -fn v4_unicast_source_strategy() -> impl Strategy { - any::().prop_filter_map("must be unicast", |bits| { - let addr = Ipv4Addr::from(bits); - if addr.is_multicast() || addr.is_broadcast() || addr.is_loopback() { - None - } else { - Some(addr) - } - }) -} - -/// Strategy for generating valid unicast IPv6 sources. -fn v6_unicast_source_strategy() -> impl Strategy { - any::().prop_filter_map("must be unicast", |bits| { - let addr = Ipv6Addr::from(bits); - if addr.is_multicast() || addr.is_loopback() { - None - } else { - Some(addr) - } - }) -} +// 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![ - // 224.0.1.0 - 224.255.255.255 (skip 224.0.0.x link-local) - (0u8..=255, 1u8..=255, any::()).prop_filter_map( - "224.0.1+ range", - |(b, c, d)| { - // 224.0.1+ is valid (224.0.0.x is link-local) - if b == 0 && c == 0 { - return None; - } - MulticastAddrV4::new(Ipv4Addr::new(224, b, c, d)).ok() + // 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") } ), - // 225.x.x.x - 231.x.x.x (globally routable) - (225u8..=231, any::(), any::(), any::()).prop_map( - |(a, b, c, d)| { + // (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("225-231 is valid multicast") + .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") } ), - // 232.x.x.x (SSM range) - (any::(), any::(), any::()).prop_map(|(b, c, d)| { - MulticastAddrV4::new(Ipv4Addr::new(232, b, c, d)) - .expect("232 is valid SSM") - }), - // 233.x.x.x - 238.x.x.x (GLOP, admin-scoped, etc.) - (233u8..=238, any::(), any::(), any::()).prop_map( - |(a, b, c, d)| { + // (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("233-238 is valid multicast") + .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") } ), - // 239.x.x.x (admin-scoped) - (any::(), any::(), any::()).prop_map(|(b, c, d)| { - MulticastAddrV4::new(Ipv4Addr::new(239, b, c, d)) - .expect("239 is valid admin-scoped") - }), ] .boxed() } @@ -518,8 +516,9 @@ impl Arbitrary for MulticastAddrV6 { // Flags: 0-f (all combinations valid) (0x0u8..=0xf, 0x3u8..=0xf, any::<[u16; 7]>()) .prop_map(|(flags, scope, segs)| { - let first_segment = - 0xff00 | ((flags as u16) << 4) | (scope as u16); + let first_segment = IPV6_MULTICAST_PREFIX + | ((flags as u16) << 4) + | (scope as u16); let addr = Ipv6Addr::new( first_segment, segs[0], @@ -550,122 +549,163 @@ impl Arbitrary for MulticastAddr { } } +// 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 { - // Split into distinct cases to avoid complex filtering: - // 1. ASM (*,G) - no source required - // 2. ASM (S,G) - optional source - // 3. SSM (S,G) - source required - let vni_strategy = 0u32..=Vni::MAX_VNI; + // Generate directly without filtering for efficiency with high case counts + let vni = 0u32..=Vni::MAX_VNI; prop_oneof![ - // V4 ASM (*,G): any non-SSM group, no source - (any::(), vni_strategy.clone()).prop_filter_map( - "ASM v4 (*,G)", - |(grp, vni)| { - if IPV4_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { - source: None, + // 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 ASM (S,G): any non-SSM group with unicast source + }) + }), + // V4 SSM (S,G) - SSM requires source ( - v4_unicast_source_strategy(), - any::(), - vni_strategy.clone() + ipv4_unicast_strategy(), + ipv4_ssm_group_strategy(), + vni.clone() ) - .prop_filter_map( - "ASM v4 (S,G)", - |(src, grp, vni)| { - if IPV4_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { - source: Some(src), - group: grp, - vni, - })) - } - ), - // V4 SSM (S,G): SSM group requires source + .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) ( - v4_unicast_source_strategy(), - any::(), - vni_strategy.clone() + ipv6_unicast_strategy(), + ipv6_asm_group_strategy(), + vni.clone() ) - .prop_filter_map( - "SSM v4 (S,G)", - |(src, grp, vni)| { - if !IPV4_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V4(MulticastRouteKeyV4 { - source: Some(src), - group: grp, - vni, - })) - } - ), - // V6 ASM (*,G): any non-SSM group, no source - (any::(), vni_strategy.clone()).prop_filter_map( - "ASM v6 (*,G)", - |(grp, vni)| { - if IPV6_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V6(MulticastRouteKeyV6 { - source: None, + .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, - })) + }) } ), - // V6 ASM (S,G): any non-SSM group with unicast source - ( - v6_unicast_source_strategy(), - any::(), - vni_strategy.clone() - ) - .prop_filter_map( - "ASM v6 (S,G)", - |(src, grp, vni)| { - if IPV6_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V6(MulticastRouteKeyV6 { - source: Some(src), - group: grp, - vni, - })) - } - ), - // V6 SSM (S,G): SSM group requires source - ( - v6_unicast_source_strategy(), - any::(), - vni_strategy - ) - .prop_filter_map( - "SSM v6 (S,G)", - |(src, grp, vni)| { - if !IPV6_SSM_SUBNET.contains(grp.ip()) { - return None; - } - Some(MulticastRouteKey::V6(MulticastRouteKeyV6 { - source: Some(src), - group: grp, - vni, - })) - } - ), ] .boxed() } @@ -676,14 +716,14 @@ proptest! { #[test] fn prop_multicast_addr_v4_arbitrary_valid(addr in any::()) { // Arbitrary impl only generates valid addresses - prop_assert_eq!(addr.ip().is_multicast(), true); + 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_eq!(addr.ip().is_multicast(), true); + prop_assert!(addr.ip().is_multicast()); } /// Property: IPv4 unicast addresses are rejected as multicast @@ -708,8 +748,9 @@ proptest! { /// Property: IPv4 link-local multicast (224.0.0.x) is rejected #[test] - fn prop_multicast_addr_v4_rejects_link_local(last_octet in 0u8..=255) { - let addr = Ipv4Addr::new(224, 0, 0, last_octet); + 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(), @@ -720,8 +761,9 @@ proptest! { /// 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( - 0xff02, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], ); let result = MulticastAddrV6::new(link_local); prop_assert!( @@ -733,8 +775,9 @@ proptest! { /// 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( - 0xff01, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], + prefix, segs[0], segs[1], segs[2], segs[3], segs[4], segs[5], segs[6], ); let result = MulticastAddrV6::new(if_local); prop_assert!( @@ -770,8 +813,7 @@ proptest! { /// Property: (*,G) with ASM group validates (source optional for ASM) #[test] - fn prop_route_key_asm_star_g_valid_v4(group in any::()) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); + 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(), @@ -780,8 +822,7 @@ proptest! { /// Property: (*,G) with ASM group validates (source optional for ASM) #[test] - fn prop_route_key_asm_star_g_valid_v6(group in any::()) { - prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); + 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(), @@ -817,40 +858,34 @@ proptest! { /// Property: SSM without source fails validation (IPv4) #[test] - fn prop_route_key_ssm_requires_source_v4(addr in ipv4_ssm_strategy()) { - let group = MulticastAddrV4::new(addr).expect("valid ssm"); + 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 {addr} should require source" + "SSM (*,G) with {group} should require source" ); } /// Property: SSM without source fails validation (IPv6) #[test] - fn prop_route_key_ssm_requires_source_v6(addr in ipv6_ssm_strategy()) { - // Filter to ensure we have a valid SSM address - prop_assume!(IPV6_SSM_SUBNET.contains(addr)); - if let Ok(group) = MulticastAddrV6::new(addr) { - let key = MulticastRouteKey::any_source(group.into()); - prop_assert!( - key.validate().is_err(), - "SSM (*,G) with {addr} should require source" - ); - } + 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(), - addr in ipv4_ssm_strategy(), + group in ipv4_ssm_group_strategy(), ) { - let group = MulticastAddrV4::new(addr).expect("valid ssm"); let key = MulticastRouteKey::source_specific_v4(src, group); prop_assert!( key.validate().is_ok(), - "SSM (S,G) with {src},{addr} should be valid" + "SSM (S,G) with {src},{group} should be valid" ); } @@ -858,16 +893,13 @@ proptest! { #[test] fn prop_route_key_ssm_with_source_valid_v6( src in ipv6_unicast_strategy(), - addr in ipv6_ssm_strategy(), + group in ipv6_ssm_group_strategy(), ) { - prop_assume!(IPV6_SSM_SUBNET.contains(addr)); - if let Ok(group) = MulticastAddrV6::new(addr) { - let key = MulticastRouteKey::source_specific_v6(src, group); - prop_assert!( - key.validate().is_ok(), - "SSM (S,G) with {src},{addr} should be valid" - ); - } + 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 @@ -911,6 +943,63 @@ proptest! { ); } + /// 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( @@ -949,9 +1038,8 @@ proptest! { #[test] fn prop_route_key_multicast_source_rejected_v4( src in any::(), - group in any::(), + group in ipv4_asm_group_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::source_specific_v4(src.ip(), group); prop_assert!( key.validate().is_err(), @@ -963,9 +1051,8 @@ proptest! { #[test] fn prop_route_key_multicast_source_rejected_v6( src in any::(), - group in any::(), + group in ipv6_asm_group_strategy(), ) { - prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::source_specific_v6(src.ip(), group); prop_assert!( key.validate().is_err(), @@ -976,10 +1063,9 @@ proptest! { /// Property: Route with admin-local underlay group passes validation #[test] fn prop_route_admin_local_underlay_valid( - group in any::(), + group in ipv4_asm_group_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let route = MulticastRoute::new( key, @@ -995,12 +1081,9 @@ proptest! { /// Property: Route with non-admin-local underlay fails validation #[test] fn prop_route_non_admin_local_underlay_invalid( - group in any::(), - underlay in any::(), + group in ipv4_asm_group_strategy(), + underlay in non_admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); - // Only test if underlay is not already admin-local - prop_assume!(underlay.ip().segments()[0] != IPV6_ADMIN_SCOPED_MULTICAST_PREFIX); let key = MulticastRouteKey::any_source(group.into()); let route = MulticastRoute::new( key, @@ -1016,11 +1099,10 @@ proptest! { /// Property: Unicast RPF neighbor passes validation (v4 group, v4 rpf) #[test] fn prop_route_unicast_rpf_valid_v4( - group in any::(), + group in ipv4_asm_group_strategy(), rpf in ipv4_unicast_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1037,11 +1119,10 @@ proptest! { /// Property: Unicast RPF neighbor passes validation (v6 group, v6 rpf) #[test] fn prop_route_unicast_rpf_valid_v6( - group in any::(), + group in ipv6_asm_group_strategy(), rpf in ipv6_unicast_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1058,11 +1139,10 @@ proptest! { /// Property: Multicast RPF neighbor fails validation (IPv4) #[test] fn prop_route_multicast_rpf_invalid_v4( - group in any::(), + group in ipv4_asm_group_strategy(), rpf in any::(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1079,11 +1159,10 @@ proptest! { /// Property: Multicast RPF neighbor fails validation (IPv6) #[test] fn prop_route_multicast_rpf_invalid_v6( - group in any::(), + group in ipv6_asm_group_strategy(), rpf in any::(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1100,11 +1179,10 @@ proptest! { /// Property: RPF AF mismatch fails validation (v4 rpf, v6 group) #[test] fn prop_route_rpf_af_mismatch_v4_v6( - group in any::(), + group in ipv6_asm_group_strategy(), rpf in ipv4_unicast_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV6_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1121,11 +1199,10 @@ proptest! { /// Property: RPF AF mismatch fails validation (v6 rpf, v4 group) #[test] fn prop_route_rpf_af_mismatch_v6_v4( - group in any::(), + group in ipv4_asm_group_strategy(), rpf in ipv6_unicast_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1142,11 +1219,10 @@ proptest! { /// Property: Routable unicast underlay nexthops pass validation #[test] fn prop_route_routable_nexthop_valid( - group in any::(), + group in ipv4_asm_group_strategy(), nexthop in routable_ipv6_unicast_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1163,11 +1239,10 @@ proptest! { /// Property: Multicast underlay nexthop fails validation #[test] fn prop_route_multicast_nexthop_invalid( - group in any::(), + group in ipv4_asm_group_strategy(), nexthop in any::(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1184,11 +1259,10 @@ proptest! { /// Property: Link-local underlay nexthop fails validation #[test] fn prop_route_link_local_nexthop_invalid( - group in any::(), + group in ipv4_asm_group_strategy(), segs in any::<[u16; 7]>(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); // Create a link-local address (fe80::/10) let link_local = Ipv6Addr::new( 0xfe80, @@ -1211,10 +1285,9 @@ proptest! { /// Property: Loopback underlay nexthop fails validation #[test] fn prop_route_loopback_nexthop_invalid( - group in any::(), + group in ipv4_asm_group_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, @@ -1231,10 +1304,9 @@ proptest! { /// Property: Unspecified underlay nexthop fails validation #[test] fn prop_route_unspecified_nexthop_invalid( - group in any::(), + group in ipv4_asm_group_strategy(), underlay in admin_local_multicast_strategy(), ) { - prop_assume!(!IPV4_SSM_SUBNET.contains(group.ip())); let key = MulticastRouteKey::any_source(group.into()); let mut route = MulticastRoute::new( key, From 5d295ad3efdabf7151e5a419ac4ffab5ae7a57a8 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Sun, 11 Jan 2026 22:23:33 -0500 Subject: [PATCH 6/7] [clippy] --- mg-common/src/test.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mg-common/src/test.rs b/mg-common/src/test.rs index ab2bce0f..0a189943 100644 --- a/mg-common/src/test.rs +++ b/mg-common/src/test.rs @@ -735,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", @@ -745,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()) From cc425b0863be5884058c4f27b9932603c43ae969 Mon Sep 17 00:00:00 2001 From: Zeeshan Lakhani Date: Tue, 13 Jan 2026 03:15:38 +0000 Subject: [PATCH 7/7] [deps] poptrie update to main --- Cargo.lock | 2 +- Cargo.toml | 2 +- rdb/src/mrib/rpf.rs | 8 ++------ 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a982a81..7a245578 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4779,7 +4779,7 @@ dependencies = [ [[package]] name = "poptrie" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/poptrie?branch=multipath#ca52bef3f87ff1a67d81b3c6e601dcb5fdbcc165" +source = "git+https://github.com/oxidecomputer/poptrie?branch=main#5bf62f6b889c61e0608d8463ed11da28e130cb34" [[package]] name = "portable-atomic" diff --git a/Cargo.toml b/Cargo.toml index 1cbb1ddc..3a84bd15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,7 +104,7 @@ 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" } -poptrie = { git = "https://github.com/oxidecomputer/poptrie", branch = "multipath" } +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/rdb/src/mrib/rpf.rs b/rdb/src/mrib/rpf.rs index 29928d7f..4b8ed6cb 100644 --- a/rdb/src/mrib/rpf.rs +++ b/rdb/src/mrib/rpf.rs @@ -255,9 +255,7 @@ impl RebuildJob { Self::V4 { rib, cache } => { let snapshot = { let r = lock!(rib); - RpfTable::snapshot_rib(&r, |p| { - (u32::from(p.value), p.length) - }) + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) }; let mut table = poptrie::Ipv4RoutingTable::default(); for (addr, len, paths) in snapshot { @@ -268,9 +266,7 @@ impl RebuildJob { Self::V6 { rib, cache } => { let snapshot = { let r = lock!(rib); - RpfTable::snapshot_rib(&r, |p| { - (u128::from(p.value), p.length) - }) + RpfTable::snapshot_rib(&r, |p| (p.value.octets(), p.length)) }; let mut table = poptrie::Ipv6RoutingTable::default(); for (addr, len, paths) in snapshot {