diff --git a/.github/buildomat/jobs/build-interop.sh b/.github/buildomat/jobs/build-interop.sh index 5ac573b1..a1e1911f 100755 --- a/.github/buildomat/jobs/build-interop.sh +++ b/.github/buildomat/jobs/build-interop.sh @@ -12,7 +12,6 @@ #: "=/work/testbed.tar.gz", #: "=/work/dhcp-server", #: ] -#: set -x set -e diff --git a/.github/buildomat/jobs/build.sh b/.github/buildomat/jobs/build.sh index 1a894585..f4fbd8bb 100755 --- a/.github/buildomat/jobs/build.sh +++ b/.github/buildomat/jobs/build.sh @@ -20,6 +20,11 @@ #: from_output = "/work/release/ddmadm" #: #: [[publish]] +#: series = "release" +#: name = "falcon-lab" +#: from_output = "/work/release/falcon-lab" +#: +#: [[publish]] #: series = "debug" #: name = "ddmd" #: from_output = "/work/debug/ddmd" @@ -29,6 +34,26 @@ #: name = "ddmadm" #: from_output = "/work/debug/ddmadm" #: +#: [[publish]] +#: series = "debug" +#: name = "mgd" +#: from_output = "/work/debug/mgd" +#: +#: [[publish]] +#: series = "debug" +#: name = "mgadm" +#: from_output = "/work/debug/mgadm" +#: +#: [[publish]] +#: series = "release" +#: name = "mgd" +#: from_output = "/work/release/mgd" +#: +#: [[publish]] +#: series = "release" +#: name = "mgadm" +#: from_output = "/work/release/mgadm" +#: set -o errexit set -o pipefail @@ -41,6 +66,7 @@ rustc --version banner "check" cargo fmt -- --check cargo clippy --all-targets -- --deny warnings +cargo xtask openapi check banner "build" ptime -m cargo build @@ -51,4 +77,8 @@ do mkdir -p /work/$x cp target/$x/ddmd /work/$x/ddmd cp target/$x/ddmadm /work/$x/ddmadm + cp target/$x/mgd /work/$x/mgd + cp target/$x/mgadm /work/$x/mgadm done + +cp target/release/falcon-lab /work/release/falcon-lab diff --git a/.github/buildomat/jobs/falcon-lab.sh b/.github/buildomat/jobs/falcon-lab.sh new file mode 100644 index 00000000..b1c69d5c --- /dev/null +++ b/.github/buildomat/jobs/falcon-lab.sh @@ -0,0 +1,58 @@ +#!/bin/bash +#: +#: name = "falcon" +#: variety = "basic" +#: target = "lab-2.0-gimlet" +#: skip_clone = true +#: +#: [dependencies.build-interop] +#: job = "build-interop" +#: +#: [dependencies.build] +#: job = "build" +#: + +set -x +set -e + +banner 'zpool' + +# pick the largest disk available +DISK=$(pfexec diskinfo -pH | sort -k8 -n -r | head -1 | awk '{print $2}') +export DISK +pfexec zpool create -o ashift=12 -f cpool "$DISK" +pfexec zfs create -o mountpoint=/ci cpool/ci + +if [[ $(curl -s http://catacomb.eng.oxide.computer:12346/trim-me) =~ "true" ]]; then + pfexec zpool trim cpool + while [[ ! $(zpool status -t cpool) =~ "100%" ]]; do sleep 10; done +fi + +pfexec chown "$UID" /ci +cd /ci +export FALCON_DATASET="cpool/falcon" + +banner 'setup' + +cp /input/build-interop/work/dhcp-server . +cp /input/build/work/release/falcon-lab . +cp /input/build/work/release/mgd . +cp /input/build/work/release/ddmd . + +chmod +x dhcp-server falcon-lab mgd ddmd + +mkdir -p cargo-bay +mv mgd cargo-bay/ +mv ddmd cargo-bay/ + +export EXT_INTERFACE=${EXT_INTERFACE:-igb0} + +first=$(bmat address ls -f extra -Ho first) +last=$(bmat address ls -f extra -Ho last) +gw=$(bmat address ls -f extra -Ho gateway) +server=$(ipadm show-addr "$EXT_INTERFACE"/dhcp -po ADDR | sed 's#/.*##g') +pfexec ./dhcp-server "$first" "$last" "$gw" "$server" &> /work/dhcp-server.log & + +RUST_LOG=debug pfexec ./falcon-lab run \ + --dendrite-commit 0c2ab6c341bf9e3802c688961b3bc687b941a144 \ + trio-unnumbered diff --git a/.github/buildomat/jobs/test-interop.sh b/.github/buildomat/jobs/test-interop.sh index aee6c8f2..b7f48d8e 100755 --- a/.github/buildomat/jobs/test-interop.sh +++ b/.github/buildomat/jobs/test-interop.sh @@ -7,13 +7,14 @@ #: output_rules = [ #: "/work/*", #: ] +# +#: enable = false #: #: [dependencies.build-interop] #: job = "build-interop" #: #: [dependencies.image] #: job = "image" -#: set -x set -e diff --git a/Cargo.lock b/Cargo.lock index 368d5fd8..8102d980 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "version_check", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -115,19 +115,22 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "api_identity" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -161,7 +164,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -183,7 +186,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -194,7 +197,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -265,9 +268,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base16ct" -version = "0.3.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b59d472eab27ade8d770dcb11da7201c11234bef9f82ce7aa517be028d462b" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" [[package]] name = "base64" @@ -283,9 +286,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" [[package]] name = "bcs" @@ -307,7 +310,7 @@ dependencies = [ "pretty_assertions", "rand 0.8.5", "rdb", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -335,7 +338,7 @@ dependencies = [ "rand 0.8.5", "rdb", "rhai", - "schemars", + "schemars 0.8.22", "serde", "serial_test", "slog", @@ -347,7 +350,7 @@ dependencies = [ [[package]] name = "bhyve_api" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "bhyve_api_sys", "libc", @@ -357,7 +360,7 @@ dependencies = [ [[package]] name = "bhyve_api_sys" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "libc", "strum 0.26.3", @@ -404,15 +407,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", "memmap2", "rayon-core", ] @@ -429,7 +433,7 @@ dependencies = [ [[package]] name = "bootstore" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "bytes", "camino", @@ -532,14 +536,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" dependencies = [ "serde", - "toml 0.9.9+spec-1.0.0", + "toml 0.9.11+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.49" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", @@ -678,7 +682,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -690,7 +694,16 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clickhouse-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "clickhouse-admin-types-versions", + "omicron-workspace-hack", +] + +[[package]] +name = "clickhouse-admin-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "atomicwrites", @@ -703,7 +716,7 @@ dependencies = [ "itertools 0.14.0", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -718,7 +731,7 @@ dependencies = [ "camino", "clap", "derive_more", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 1.0.69", @@ -746,13 +759,23 @@ dependencies = [ [[package]] name = "cockroach-admin-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "cockroach-admin-types-versions", + "omicron-workspace-hack", + "serde", +] + +[[package]] +name = "cockroach-admin-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "chrono", "csv", - "omicron-common", + "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "thiserror 2.0.17", ] @@ -775,14 +798,14 @@ dependencies = [ [[package]] name = "common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/dendrite?branch=main#ab30fa91227fd478bfe0e023310ca83dec0bc22b" +source = "git+https://github.com/oxidecomputer/dendrite?branch=ry%2Fv4-over-v6-routes#f486ffe91f5bd6e767891b3af018436219edf5f2" dependencies = [ "anyhow", "chrono", "oximeter", "oxnet", "rand 0.9.2", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -862,9 +885,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -969,7 +992,7 @@ source = "git+https://github.com/oxidecomputer/crucible?rev=7103cd3a3d7b0112d294 dependencies = [ "base64 0.22.1", "crucible-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -1078,7 +1101,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1102,7 +1125,7 @@ checksum = "27c6a4a4003df965e441d13b2a7044efa44334b567c984701f8a2773f815c5e2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1126,7 +1149,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1137,7 +1160,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1164,13 +1187,13 @@ dependencies = [ "libnet", "mg-common", "omicron-common", - "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "opte-ioctl 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "oximeter", "oximeter-producer", "oxnet", "pretty_assertions", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sled", @@ -1202,7 +1225,7 @@ dependencies = [ "dropshot-api-manager-types", "mg-common", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "uuid", ] @@ -1213,7 +1236,7 @@ version = "0.1.0" dependencies = [ "mg-common", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "serde_repr", ] @@ -1292,7 +1315,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1311,6 +1334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -1322,7 +1346,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1333,7 +1357,7 @@ checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1346,7 +1370,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1374,13 +1398,13 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "dlpi" version = "0.2.0" -source = "git+https://github.com/oxidecomputer/dlpi-sys#2f5c441a8c0902d547e55a72346054709252cb18" +source = "git+https://github.com/oxidecomputer/dlpi-sys#42b2bfeefdfb8c7b96fc6cfa9ec45ef4554c2714" dependencies = [ "libc", "libdlpi-sys", @@ -1414,13 +1438,13 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.17", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] name = "dpd-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/dendrite?branch=main#ab30fa91227fd478bfe0e023310ca83dec0bc22b" +source = "git+https://github.com/oxidecomputer/dendrite?branch=ry%2Fv4-over-v6-routes#f486ffe91f5bd6e767891b3af018436219edf5f2" dependencies = [ "async-trait", "chrono", @@ -1432,7 +1456,7 @@ dependencies = [ "progenitor 0.11.2", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -1448,7 +1472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9ba64b39d5fd68e09169e63c8e82b7a50c9b6082f2c44f52db2a11e3b9d7dd4" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.0", "openapiv3", "regex", "serde", @@ -1476,14 +1500,14 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", - "indexmap", + "indexmap 2.13.0", "multer", "openapiv3", "paste", "percent-encoding", "rustls 0.22.4", "rustls-pemfile", - "schemars", + "schemars 0.8.22", "scopeguard", "semver 1.0.27", "serde", @@ -1499,7 +1523,7 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-rustls 0.25.0", - "toml 0.9.9+spec-1.0.0", + "toml 0.9.11+spec-1.1.0", "usdt 0.6.0", "uuid", "version_check", @@ -1561,7 +1585,7 @@ dependencies = [ "semver 1.0.27", "serde", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1658,7 +1682,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1679,12 +1703,12 @@ dependencies = [ [[package]] name = "ereport-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "dropshot", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 2.0.17", @@ -1712,6 +1736,27 @@ dependencies = [ "similar", ] +[[package]] +name = "falcon-lab" +version = "0.1.0" +dependencies = [ + "anyhow", + "bgp", + "clap", + "colored", + "ddm-admin-client", + "dpd-client", + "libfalcon", + "mg-admin-client 0.1.0", + "oxide-tokio-rt", + "oxnet", + "rdb-types 0.1.0", + "serde", + "serde_json", + "slog", + "tokio", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1748,9 +1793,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "fixedbitset" @@ -1825,7 +1870,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1851,9 +1896,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.1" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824f08d01d0f496b3eca4f001a13cf17690a6ee930043d20817f547455fd98f8" +checksum = "baf68cef89750956493a66a10f512b9e58d9db21f2a573c079c0bdf1207a54a7" dependencies = [ "autocfg", ] @@ -1930,7 +1975,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1975,7 +2020,7 @@ dependencies = [ [[package]] name = "gateway-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "base64 0.22.1", "chrono", @@ -1988,7 +2033,7 @@ dependencies = [ "progenitor 0.10.0", "rand 0.9.2", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -2011,13 +2056,13 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.27.2", "uuid", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] name = "gateway-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "gateway-types-versions", "omicron-workspace-hack", @@ -2026,7 +2071,7 @@ dependencies = [ [[package]] name = "gateway-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "daft", "dropshot", @@ -2034,7 +2079,7 @@ dependencies = [ "hex", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "thiserror 2.0.17", "tufaceous-artifact", @@ -2089,6 +2134,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gfss" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "digest", + "omicron-workspace-hack", + "rand 0.9.2", + "schemars 0.8.22", + "secrecy", + "serde", + "subtle", + "thiserror 2.0.17", + "zeroize", +] + [[package]] name = "glob" version = "0.3.3" @@ -2143,9 +2204,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -2153,7 +2214,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2168,7 +2229,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -2180,6 +2241,12 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2478,7 +2545,7 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -2631,7 +2698,7 @@ dependencies = [ "hashbrown 0.16.1", "ref-cast", "rustc-hash", - "schemars", + "schemars 0.8.22", "serde_core", "serde_json", ] @@ -2676,7 +2743,7 @@ dependencies = [ [[package]] name = "illumos-sys-hdrs" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ "bitflags 2.10.0", ] @@ -2692,7 +2759,7 @@ dependencies = [ [[package]] name = "illumos-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "async-trait", @@ -2701,6 +2768,7 @@ dependencies = [ "camino", "camino-tempfile", "cfg-if", + "chrono", "crucible-smf", "debug-ignore", "dropshot", @@ -2718,10 +2786,12 @@ dependencies = [ "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80)", "oxlog", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "slog", + "slog-async", "slog-error-chain", + "slog-term", "smf 0.2.3", "thiserror 2.0.17", "tofino", @@ -2739,9 +2809,20 @@ checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3" [[package]] name = "indexmap" -version = "2.12.1" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -2773,7 +2854,7 @@ dependencies = [ "ingot-types", "macaddr", "serde", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -2787,7 +2868,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2798,7 +2879,7 @@ checksum = "2d0d55db2f1de52564cc3781ffd5a7ebb7f2c6e1888841c2fa54231a9498db5f" dependencies = [ "ingot-macros", "macaddr", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -2822,7 +2903,7 @@ dependencies = [ [[package]] name = "internal-dns-resolver" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "futures", "hickory-proto 0.25.2", @@ -2840,18 +2921,38 @@ dependencies = [ [[package]] name = "internal-dns-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "chrono", + "internal-dns-types-versions", "omicron-common", "omicron-uuid-kinds", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "strum 0.27.2", ] +[[package]] +name = "internal-dns-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "anyhow", + "chrono", + "omicron-common", + "omicron-workspace-hack", + "schemars 0.8.22", + "serde", +] + +[[package]] +name = "internet-checksum" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6d6206008e25125b1f97fbe5d309eb7b85141cf9199d52dbd3729a1584dd16" + [[package]] name = "ipconfig" version = "0.3.2" @@ -2876,15 +2977,15 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", ] [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -2941,15 +3042,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "jiff-tzdb-platform", @@ -2962,13 +3063,13 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3018,10 +3119,10 @@ dependencies = [ [[package]] name = "kstat-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3030,7 +3131,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=795a1e0aeefb7a2c6fe4139779fdf66930d09b80#795a1e0aeefb7a2c6fe4139779fdf66930d09b80" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3049,23 +3150,23 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.179" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libdlpi-sys" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/dlpi-sys#2f5c441a8c0902d547e55a72346054709252cb18" +source = "git+https://github.com/oxidecomputer/dlpi-sys#42b2bfeefdfb8c7b96fc6cfa9ec45ef4554c2714" [[package]] name = "libfalcon" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/falcon?branch=main#45a8b502e3c151e02f6b4acc4ffcdd3f2152c3e7" +source = "git+https://github.com/oxidecomputer/falcon?branch=main#e6e4c1c3db3859d3d91a37c2618be2ee4f003012" dependencies = [ "anstyle", "anyhow", - "base16ct 0.3.0", + "base16ct 1.0.0", "camino", "cargo_toml", "clap", @@ -3089,12 +3190,12 @@ dependencies = [ "slog-envlogger", "slog-term", "smf 0.2.3", - "syn 2.0.111", + "syn 2.0.114", "tabwriter", "thiserror 1.0.69", "tokio", "tokio-tungstenite", - "toml 0.9.9+spec-1.0.0", + "toml 0.9.11+spec-1.1.0", "uuid", "xz2", "zone 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", @@ -3109,7 +3210,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libnet" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/netadm-sys?branch=main#763dbb28fe66eb726f43872b5d979c58eb15de6b" +source = "git+https://github.com/oxidecomputer/netadm-sys?branch=main#6c94b3c4fa494b065d065b32b6186360b0517908" dependencies = [ "anyhow", "cfg-if", @@ -3130,13 +3231,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.7.0", ] [[package]] @@ -3317,7 +3418,7 @@ dependencies = [ "progenitor 0.11.2", "rdb-types 0.1.0", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3328,14 +3429,14 @@ dependencies = [ [[package]] name = "mg-admin-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a#0df320d42b356e689a3c7a7600eec9b16770237a" +source = "git+https://github.com/oxidecomputer/maghemite?rev=205b3ccf75b527ac7a565285fdcc0c78f4fcee95#205b3ccf75b527ac7a565285fdcc0c78f4fcee95" dependencies = [ "chrono", "colored", "progenitor 0.11.2", - "rdb-types 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a)", + "rdb-types 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=205b3ccf75b527ac7a565285fdcc0c78f4fcee95)", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3349,10 +3450,11 @@ version = "0.1.0" dependencies = [ "bfd", "bgp", + "chrono", "dropshot", "dropshot-api-manager-types", "rdb", - "schemars", + "schemars 0.8.22", "serde", ] @@ -3369,7 +3471,7 @@ dependencies = [ "oximeter", "oximeter-producer", "oxnet", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -3407,6 +3509,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "util", + "uuid", ] [[package]] @@ -3446,6 +3549,7 @@ dependencies = [ "humantime", "mg-admin-client 0.1.0", "mg-common", + "natord", "oxide-tokio-rt", "oxnet", "rdb", @@ -3470,9 +3574,12 @@ dependencies = [ "gateway-client", "hostname 0.3.1", "http", + "humantime", "mg-api", "mg-common", "mg-lower", + "ndp", + "network-interface", "omicron-common", "oxide-tokio-rt", "oximeter", @@ -3523,9 +3630,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.11" +version = "0.12.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -3533,7 +3640,6 @@ dependencies = [ "equivalent", "parking_lot 0.12.5", "portable-atomic", - "rustc_version 0.4.1", "smallvec", "tagptr", "uuid", @@ -3556,6 +3662,39 @@ dependencies = [ "version_check", ] +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "ndp" +version = "0.1.0" +dependencies = [ + "internet-checksum", + "ispf", + "libc", + "mg-common", + "network-interface", + "oxnet", + "serde", + "slog", + "socket2 0.5.10", + "thiserror 1.0.69", +] + +[[package]] +name = "network-interface" +version = "0.1.7" +source = "git+https://github.com/oxidecomputer/network-interface?branch=illumos#5a696e910333bdc50ef56cebe9cdd78e40127d87" +dependencies = [ + "cc", + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "newline-converter" version = "0.3.0" @@ -3571,7 +3710,7 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -3588,7 +3727,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3603,7 +3742,7 @@ dependencies = [ [[package]] name = "nexus-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "chrono", "futures", @@ -3616,7 +3755,7 @@ dependencies = [ "progenitor 0.10.0", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -3626,7 +3765,7 @@ dependencies = [ [[package]] name = "nexus-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "api_identity", @@ -3651,6 +3790,7 @@ dependencies = [ "illumos-utils", "indent_write", "internal-dns-types", + "ipnet", "ipnetwork", "itertools 0.14.0", "newtype-uuid", @@ -3665,11 +3805,12 @@ dependencies = [ "oxql-types", "parse-display", "regex", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", "serde_with", + "sled-agent-types", "sled-agent-types-versions", "sled-hardware-types", "slog", @@ -3683,6 +3824,8 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tough", + "trust-quorum-protocol", + "trust-quorum-types", "tufaceous-artifact", "unicode-width 0.1.14", "update-engine", @@ -3769,7 +3912,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3837,7 +3980,7 @@ version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 1.0.109", @@ -3849,10 +3992,10 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3893,7 +4036,7 @@ dependencies = [ [[package]] name = "omicron-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "api_identity", @@ -3910,7 +4053,7 @@ dependencies = [ "ipnetwork", "itertools 0.14.0", "macaddr", - "mg-admin-client 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a)", + "mg-admin-client 0.1.0 (git+https://github.com/oxidecomputer/maghemite?rev=205b3ccf75b527ac7a565285fdcc0c78f4fcee95)", "omicron-uuid-kinds", "omicron-workspace-hack", "oxnet", @@ -3920,7 +4063,7 @@ dependencies = [ "rand 0.9.2", "regress", "reqwest", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_human_bytes", @@ -3938,12 +4081,12 @@ dependencies = [ [[package]] name = "omicron-passwords" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "argon2", "omicron-workspace-hack", "rand 0.9.2", - "schemars", + "schemars 0.8.22", "secrecy", "serde", "serde_with", @@ -3953,7 +4096,7 @@ dependencies = [ [[package]] name = "omicron-rpaths" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "omicron-workspace-hack", ] @@ -3961,13 +4104,13 @@ dependencies = [ [[package]] name = "omicron-uuid-kinds" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "daft", "newtype-uuid", "newtype-uuid-macros", "paste", - "schemars", + "schemars 0.8.22", ] [[package]] @@ -4036,7 +4179,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_json", ] @@ -4064,7 +4207,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4082,20 +4225,20 @@ dependencies = [ [[package]] name = "opte" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ "bitflags 2.10.0", "dyn-clone", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "ingot", - "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", - "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "kstat-macro 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", + "opte-api 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "postcard", "ref-cast", "serde", "tabwriter", "version_check", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -4114,15 +4257,15 @@ dependencies = [ "serde", "tabwriter", "version_check", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] name = "opte-api" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "ingot", "ipnetwork", "postcard", @@ -4146,12 +4289,12 @@ dependencies = [ [[package]] name = "opte-ioctl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ "libc", "libnet", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", - "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", + "oxide-vpc 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "postcard", "serde", "thiserror 2.0.17", @@ -4191,15 +4334,15 @@ dependencies = [ [[package]] name = "oxide-vpc" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d#0f048374110d75ae61743ae3ec0de96960a2848d" +source = "git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7#4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" dependencies = [ "cfg-if", - "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", - "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=0f048374110d75ae61743ae3ec0de96960a2848d)", + "illumos-sys-hdrs 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", + "opte 0.1.0 (git+https://github.com/oxidecomputer/opte?rev=4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7)", "serde", "tabwriter", "uuid", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -4213,13 +4356,13 @@ dependencies = [ "serde", "tabwriter", "uuid", - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] name = "oximeter" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "chrono", @@ -4230,7 +4373,7 @@ dependencies = [ "oximeter-timeseries-macro", "oximeter-types", "prettyplease", - "syn 2.0.111", + "syn 2.0.114", "toml 0.8.23", "uuid", ] @@ -4238,7 +4381,7 @@ dependencies = [ [[package]] name = "oximeter-db" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "async-recursion", @@ -4257,7 +4400,7 @@ dependencies = [ "gethostname", "highway", "iana-time-zone", - "indexmap", + "indexmap 2.13.0", "libc", "nom", "num", @@ -4271,7 +4414,7 @@ dependencies = [ "quote", "regex", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -4291,18 +4434,18 @@ dependencies = [ [[package]] name = "oximeter-macro-impl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "oximeter-producer" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "chrono", "dropshot", @@ -4312,7 +4455,7 @@ dependencies = [ "omicron-common", "omicron-workspace-hack", "oximeter", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-dtrace", @@ -4324,7 +4467,7 @@ dependencies = [ [[package]] name = "oximeter-schema" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "chrono", @@ -4335,30 +4478,30 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "serde", "slog-error-chain", - "syn 2.0.111", + "syn 2.0.114", "toml 0.8.23", ] [[package]] name = "oximeter-timeseries-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "omicron-workspace-hack", "oximeter-schema", "oximeter-types", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "oximeter-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "bytes", "chrono", @@ -4369,7 +4512,7 @@ dependencies = [ "oximeter-types-versions", "parse-display", "regex", - "schemars", + "schemars 0.8.22", "serde", "strum 0.27.2", "thiserror 2.0.17", @@ -4379,12 +4522,12 @@ dependencies = [ [[package]] name = "oximeter-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "chrono", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "uuid", ] @@ -4392,7 +4535,7 @@ dependencies = [ [[package]] name = "oxlog" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "camino", @@ -4413,7 +4556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dc6fb07ecd6d2a17ff1431bc5b3ce11036c0b6dd93a3c4904db5b910817b162" dependencies = [ "ipnetwork", - "schemars", + "schemars 0.8.22", "serde", "serde_json", ] @@ -4421,7 +4564,7 @@ dependencies = [ [[package]] name = "oxql-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "chrono", @@ -4429,7 +4572,7 @@ dependencies = [ "num", "omicron-workspace-hack", "oximeter-types", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "uuid", @@ -4527,7 +4670,7 @@ dependencies = [ "regex", "regex-syntax", "structmeta", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4565,9 +4708,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", @@ -4575,9 +4718,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -4585,22 +4728,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2", @@ -4613,7 +4756,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap", + "indexmap 2.13.0", "serde", "serde_derive", ] @@ -4626,7 +4769,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.0", "serde", ] @@ -4665,7 +4808,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4705,9 +4848,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -4751,7 +4894,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.31", + "zerocopy 0.8.33", ] [[package]] @@ -4788,7 +4931,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4801,6 +4944,15 @@ dependencies = [ "toml_edit 0.19.15", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -4844,14 +4996,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] @@ -4916,15 +5068,15 @@ checksum = "b17e5363daa50bf1cccfade6b0fb970d2278758fd5cfa9ab69f25028e4b1afa3" dependencies = [ "heck 0.5.0", "http", - "indexmap", + "indexmap 2.13.0", "openapiv3", "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.114", "thiserror 2.0.17", "typify", "unicode-ident", @@ -4938,15 +5090,15 @@ checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" dependencies = [ "heck 0.5.0", "http", - "indexmap", + "indexmap 2.13.0", "openapiv3", "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.114", "thiserror 2.0.17", "typify", "unicode-ident", @@ -4962,12 +5114,12 @@ dependencies = [ "proc-macro2", "progenitor-impl 0.10.0", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4980,18 +5132,18 @@ dependencies = [ "proc-macro2", "progenitor-impl 0.11.2", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "propolis-client" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "async-trait", "base64 0.21.7", @@ -4999,10 +5151,10 @@ dependencies = [ "futures", "progenitor 0.10.0", "progenitor-client 0.10.0", - "propolis_api_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66)", + "propolis_api_types", "rand 0.9.2", "reqwest", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -5015,24 +5167,11 @@ dependencies = [ [[package]] name = "propolis_api_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" -dependencies = [ - "crucible-client-types", - "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66)", - "schemars", - "serde", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "propolis_api_types" -version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ "crucible-client-types", - "propolis_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9)", - "schemars", + "propolis_types", + "schemars 0.8.22", "serde", "thiserror 1.0.69", "uuid", @@ -5041,18 +5180,9 @@ dependencies = [ [[package]] name = "propolis_types" version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=30d32c418804cb094751673cabd86ee99bda2d66#30d32c418804cb094751673cabd86ee99bda2d66" -dependencies = [ - "schemars", - "serde", -] - -[[package]] -name = "propolis_types" -version = "0.0.0" -source = "git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9#3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9" +source = "git+https://github.com/oxidecomputer/propolis?rev=2dc643742f82d2e072a1281dab23ba2bfdcee440#2dc643742f82d2e072a1281dab23ba2bfdcee440" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", ] @@ -5081,7 +5211,7 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/lldp#61479b6922f9112fbe1e722414d2b8055212cb12" dependencies = [ "anyhow", - "schemars", + "schemars 0.8.22", "serde", "thiserror 1.0.69", ] @@ -5125,7 +5255,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.35", + "rustls 0.23.36", "socket2 0.6.1", "thiserror 2.0.17", "tokio", @@ -5145,7 +5275,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "slab", "thiserror 2.0.17", @@ -5170,9 +5300,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -5281,10 +5411,11 @@ dependencies = [ "clap", "itertools 0.14.0", "mg-common", + "ndp", "oxnet", "proptest", "rdb-types 0.1.0", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sled", @@ -5298,17 +5429,17 @@ version = "0.1.0" dependencies = [ "clap", "oxnet", - "schemars", + "schemars 0.8.22", "serde", ] [[package]] name = "rdb-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/maghemite?rev=0df320d42b356e689a3c7a7600eec9b16770237a#0df320d42b356e689a3c7a7600eec9b16770237a" +source = "git+https://github.com/oxidecomputer/maghemite?rev=205b3ccf75b527ac7a565285fdcc0c78f4fcee95#205b3ccf75b527ac7a565285fdcc0c78f4fcee95" dependencies = [ "oxnet", - "schemars", + "schemars 0.8.22", "serde", ] @@ -5332,9 +5463,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ "bitflags 2.10.0", ] @@ -5356,7 +5487,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5400,9 +5531,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -5419,7 +5550,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.35", + "rustls 0.23.36", "rustls-pki-types", "serde", "serde_json", @@ -5473,7 +5604,7 @@ checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5543,9 +5674,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -5570,9 +5701,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "log", @@ -5664,9 +5795,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" @@ -5703,6 +5834,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -5712,7 +5867,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5747,7 +5902,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5758,7 +5913,7 @@ checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5828,7 +5983,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5839,7 +5994,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5853,9 +6008,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", @@ -5892,7 +6047,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5922,7 +6077,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5946,6 +6101,11 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.8.22", + "schemars 0.9.0", + "schemars 1.2.0", "serde_core", "serde_json", "serde_with_macros", @@ -5961,7 +6121,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -5970,7 +6130,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.13.0", "itoa", "ryu", "serde", @@ -5979,11 +6139,12 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b258109f244e1d6891bf1053a55d63a5cd4f8f4c30cf9a1280989f80e7a1fa9" +checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" dependencies = [ - "futures", + "futures-executor", + "futures-util", "log", "once_cell", "parking_lot 0.12.5", @@ -5993,13 +6154,13 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.2.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d69265a08751de7844521fd15003ae0a888e035773ba05695c5c759a6f89eef" +checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6042,10 +6203,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -6101,10 +6263,42 @@ dependencies = [ "parking_lot 0.11.2", ] +[[package]] +name = "sled-agent-types" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "anyhow", + "async-trait", + "bootstore", + "camino", + "chrono", + "daft", + "iddqd", + "omicron-common", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "oxnet", + "schemars 0.8.22", + "serde", + "serde_human_bytes", + "serde_json", + "sled-agent-types-versions", + "sled-hardware-types", + "slog", + "slog-error-chain", + "strum 0.27.2", + "swrite", + "thiserror 2.0.17", + "toml 0.8.23", + "tufaceous-artifact", + "uuid", +] + [[package]] name = "sled-agent-types-versions" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "async-trait", "bootstore", @@ -6119,15 +6313,17 @@ dependencies = [ "omicron-uuid-kinds", "omicron-workspace-hack", "oxnet", - "propolis_api_types 0.0.0 (git+https://github.com/oxidecomputer/propolis?rev=3f1752e6cee9a2f8ecdce6e2ad3326781182e2d9)", - "schemars", + "propolis_api_types", + "schemars 0.8.22", "serde", "serde_json", + "serde_with", "sha3", "sled-hardware-types", "slog", "strum 0.27.2", "thiserror 2.0.17", + "trust-quorum-types-versions", "tufaceous-artifact", "uuid", ] @@ -6135,13 +6331,16 @@ dependencies = [ [[package]] name = "sled-hardware-types" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ + "daft", "illumos-utils", "omicron-common", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", + "slog", + "thiserror 2.0.17", ] [[package]] @@ -6225,7 +6424,7 @@ source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6368,7 +6567,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6428,7 +6627,7 @@ dependencies = [ "lazy_static", "newtype_derive", "petgraph 0.6.5", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "slog", @@ -6452,7 +6651,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6463,7 +6662,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6494,7 +6693,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6506,7 +6705,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6543,9 +6742,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -6569,7 +6768,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6638,7 +6837,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6675,14 +6874,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -6701,7 +6900,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.60.2", ] @@ -6721,7 +6920,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6780,7 +6979,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6791,7 +6990,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6922,9 +7121,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -6956,7 +7155,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -6976,15 +7175,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.36", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -7006,9 +7205,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -7044,14 +7243,14 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.9+spec-1.0.0" +version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb5238e643fc34a1d5d7e753e1532a91912d74b63b92b3ea51fde8d1b7bc79dd" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde_core", "serde_spanned 1.0.4", - "toml_datetime 0.7.4+spec-1.0.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.14", @@ -7068,9 +7267,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.4+spec-1.0.0" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3cea6b2aa3b910092f6abd4053ea464fab5f9c170ba5e9a6aead16ec4af2b6" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -7081,7 +7280,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7094,7 +7293,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -7102,11 +7301,23 @@ dependencies = [ "winnow 0.7.14", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.14", +] + [[package]] name = "toml_parser" -version = "1.0.5+spec-1.0.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c03bee5ce3696f31250db0bbaff18bc43301ce0e8db2ed1f07cbb2acf89984c" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow 0.7.14", ] @@ -7119,9 +7330,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.5+spec-1.0.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9cd6190959dce0994aa8970cd32ab116d1851ead27e866039acaf2524ce44fa" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "topological-sort" @@ -7150,7 +7361,7 @@ dependencies = [ "pem", "percent-encoding", "reqwest", - "rustls 0.23.35", + "rustls 0.23.36", "serde", "serde_json", "serde_plain", @@ -7211,9 +7422,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -7228,14 +7439,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -7243,14 +7454,14 @@ dependencies = [ [[package]] name = "transceiver-controller" version = "0.1.1" -source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#7cd1a05eff3b1a480ed0d573124f6784d24999b0" +source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#5f48c09e112a91ec8ff770daad359a144ff9f8f5" dependencies = [ "anyhow", "clap", "hubpack", "itertools 0.14.0", "nix", - "schemars", + "schemars 0.8.22", "serde", "slog", "slog-async", @@ -7267,9 +7478,9 @@ dependencies = [ [[package]] name = "transceiver-decode" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#7cd1a05eff3b1a480ed0d573124f6784d24999b0" +source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#5f48c09e112a91ec8ff770daad359a144ff9f8f5" dependencies = [ - "schemars", + "schemars 0.8.22", "serde", "static_assertions", "thiserror 2.0.17", @@ -7279,13 +7490,79 @@ dependencies = [ [[package]] name = "transceiver-messages" version = "0.1.1" -source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#7cd1a05eff3b1a480ed0d573124f6784d24999b0" +source = "git+https://github.com/oxidecomputer/transceiver-control?branch=main#5f48c09e112a91ec8ff770daad359a144ff9f8f5" dependencies = [ "bitflags 2.10.0", "clap", "hubpack", - "schemars", + "schemars 0.8.22", + "serde", + "thiserror 2.0.17", +] + +[[package]] +name = "trust-quorum-protocol" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "bootstore", + "bytes", + "camino", + "chacha20poly1305", + "ciborium", + "daft", + "derive_more", + "gfss", + "hex", + "hkdf", + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "rand 0.9.2", + "secrecy", + "serde", + "serde_with", + "sha3", + "sled-agent-types", + "sled-hardware-types", + "slog", + "slog-error-chain", + "static_assertions", + "subtle", + "thiserror 2.0.17", + "trust-quorum-types", + "uuid", + "zeroize", +] + +[[package]] +name = "trust-quorum-types" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "omicron-workspace-hack", + "trust-quorum-types-versions", +] + +[[package]] +name = "trust-quorum-types-versions" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" +dependencies = [ + "daft", + "derive_more", + "gfss", + "iddqd", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "rand 0.9.2", + "schemars 0.8.22", "serde", + "serde_human_bytes", + "serde_with", + "sled-hardware-types", + "slog", + "slog-error-chain", "thiserror 2.0.17", ] @@ -7303,7 +7580,7 @@ dependencies = [ "daft", "hex", "proptest", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_human_bytes", @@ -7370,11 +7647,11 @@ dependencies = [ "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.114", "thiserror 2.0.17", "unicode-ident", ] @@ -7387,12 +7664,12 @@ checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver 1.0.27", "serde", "serde_json", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", "typify-impl", ] @@ -7490,7 +7767,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "update-engine" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/omicron?branch=main#79b29e0d001efc324ce0ba851379db0545ca7356" +source = "git+https://github.com/oxidecomputer/omicron?branch=main#63d8904b88de3ca37f17450d01c59dc2167f0ebe" dependencies = [ "anyhow", "cancel-safe-futures", @@ -7500,13 +7777,13 @@ dependencies = [ "either", "futures", "indent_write", - "indexmap", + "indexmap 2.13.0", "libsw", "linear-map", "omicron-workspace-hack", "owo-colors", "petgraph 0.8.3", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_with", @@ -7519,14 +7796,15 @@ dependencies = [ [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7570,7 +7848,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", "usdt-impl 0.5.0", ] @@ -7584,7 +7862,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", "usdt-impl 0.6.0", ] @@ -7602,7 +7880,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.114", "thiserror 1.0.69", "thread-id 4.2.2", "version_check", @@ -7622,7 +7900,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.111", + "syn 2.0.114", "thiserror 2.0.17", "thread-id 5.0.0", ] @@ -7637,7 +7915,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", "usdt-impl 0.5.0", ] @@ -7651,7 +7929,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.111", + "syn 2.0.114", "usdt-impl 0.6.0", ] @@ -7830,7 +8108,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "wasm-bindgen-shared", ] @@ -7878,9 +8156,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" dependencies = [ "rustls-pki-types", ] @@ -7954,7 +8232,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -7965,7 +8243,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8281,7 +8559,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.1.3", ] [[package]] @@ -8327,7 +8605,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] @@ -8343,11 +8621,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ - "zerocopy-derive 0.8.31", + "zerocopy-derive 0.8.33", ] [[package]] @@ -8358,18 +8636,18 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8389,7 +8667,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] @@ -8404,13 +8682,13 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -8443,14 +8721,14 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "zmij" -version = "1.0.7" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de9211a9f64b825911bdf0240f58b7a8dac217fe260fc61f080a07f61372fbd5" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" [[package]] name = "zone" @@ -8502,7 +8780,7 @@ dependencies = [ [[package]] name = "ztest" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/falcon?branch=main#45a8b502e3c151e02f6b4acc4ffcdd3f2152c3e7" +source = "git+https://github.com/oxidecomputer/falcon?branch=main#f5f8fd52ea72167b6a46eff34dc7b46b87d3b5f9" dependencies = [ "anyhow", "libnet", diff --git a/Cargo.toml b/Cargo.toml index e2164bae..3ef11265 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,9 @@ default-members = [ "mgd", "mg-lower", "mg-common", + "ndp", "xtask", + "falcon-lab", ] members = [ @@ -48,11 +50,27 @@ members = [ "mgd", "mg-lower", "mg-common", + "ndp", "xtask", + "falcon-lab", ] [workspace.dependencies] -slog = { version = "2.8.2", features = ["max_level_trace", "release_max_level_debug"] } +# Local +mg-api = { path = "mg-api" } +mg-common = { path = "mg-common" } +rdb-types = { path = "rdb-types" } +ndp = { path = "ndp" } +bgp = { path = "bgp" } +bfd = { path = "bfd" } +mg-admin-client = { path = "mg-admin-client" } +ddm-admin-client = { path = "ddm-admin-client" } +rdb = { path = "rdb", features = ["clap"] } +ddm-api = { path = "ddm-api" } +ddm-types = { path = "ddm-types" } + +# External +slog = { version = "2.7.0", features = ["max_level_trace", "release_max_level_debug"] } slog-term = "2.9" slog-envlogger = "2.2" slog-async = "2.8" @@ -77,10 +95,11 @@ libnet = { git = "https://github.com/oxidecomputer/netadm-sys", branch = "main" progenitor = "0.11" progenitor-client = "0.11" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } -clap = { version = "4.5.54", features = ["derive", "unstable-styles", "env"] } +clap = { version = "4.5.53", features = ["derive", "unstable-styles", "env"] } tabwriter = { version = "1", features = ["ansi_formatting"] } colored = "3.0" ztest = { git = "https://github.com/oxidecomputer/falcon", branch = "main" } +libfalcon = { git = "https://github.com/oxidecomputer/falcon", branch = "main" } anstyle = "1.0.13" nom = "7.1" num_enum = "0.7.5" @@ -95,16 +114,13 @@ http-body-util = "0.1" humantime = "2.1" rand = "0.8.5" backoff = "0.4" -mg-api = { path = "mg-api" } -mg-common = { path = "mg-common" } -bgp = { path = "bgp" } -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.4", default-features = false, features = ["schemars", "serde"] } omicron-common = { git = "https://github.com/oxidecomputer/omicron", branch = "main"} +gateway-client = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } uuid = { version = "1.8", features = ["serde", "v4"] } smf = { git = "https://github.com/illumos/smf-rs", branch = "main" } libc = "0.2" @@ -113,19 +129,22 @@ rhai = { version = "1", features = ["metadata", "sync"] } semver = "1.0" proptest = "1.4" serial_test = "3.2" -ddm-api = { path = "ddm-api" } -ddm-types = { path = "ddm-types" } -gateway-client = { git = "https://github.com/oxidecomputer/omicron", branch = "main" } +internet-checksum = "0.2.1" +network-interface = { git = "https://github.com/oxidecomputer/network-interface", branch = "illumos" } +natord = "1.0" + [workspace.dependencies.opte-ioctl] git = "https://github.com/oxidecomputer/opte" -rev = "0f048374110d75ae61743ae3ec0de96960a2848d" +rev = "4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" [workspace.dependencies.oxide-vpc] git = "https://github.com/oxidecomputer/opte" -rev = "0f048374110d75ae61743ae3ec0de96960a2848d" +rev = "4bd8a40c0f5c05de7bb29b5f592f2dc99b4fd1d7" [workspace.dependencies.dpd-client] +#path = "/home/ry/src/dendrite/dpd-client" git = "https://github.com/oxidecomputer/dendrite" -branch = "main" +branch = "ry/v4-over-v6-routes" +#branch = "main" package = "dpd-client" diff --git a/bgp/src/connection.rs b/bgp/src/connection.rs index 01edfc34..3d1cd884 100644 --- a/bgp/src/connection.rs +++ b/bgp/src/connection.rs @@ -6,14 +6,15 @@ use crate::{ clock::ConnectionClock, error::Error, messages::Message, - session::{FsmEvent, SessionEndpoint, SessionInfo}, + session::{FsmEvent, PeerId, SessionEndpoint, SessionInfo}, + unnumbered::UnnumberedManager, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use slog::Logger; use std::{ collections::BTreeMap, - net::{IpAddr, SocketAddr, ToSocketAddrs}, + net::{SocketAddr, ToSocketAddrs}, sync::{Arc, Mutex, mpsc::Sender}, thread::JoinHandle, time::Duration, @@ -118,18 +119,25 @@ impl ConnectionId { /// Implementors of this trait listen to and accept inbound BGP connections. pub trait BgpListener { /// Bind to an address and listen for connections. - fn bind(addr: A) -> Result + /// + /// # Arguments + /// * `addr` - The address to bind to + /// * `unnumbered_manager` - Optional unnumbered manager for resolving scope_id -> interface + fn bind( + addr: A, + unnumbered_manager: Option>, + ) -> Result where Self: Sized; /// Accept a connection. This Listener is non-blocking, so the timeout /// is used as a sleep between accept attempts. This function may be called /// multiple times, returning a new connection each time. Policy application - /// is handled by the Dispatcher after the addr_to_session lookup. + /// is handled by the Dispatcher after the peer_to_session lookup. fn accept( &self, log: Logger, - addr_to_session: Arc>>>, + peer_to_session: Arc>>>, timeout: Duration, ) -> Result; diff --git a/bgp/src/connection_channel.rs b/bgp/src/connection_channel.rs index e0370ffc..808dadf4 100644 --- a/bgp/src/connection_channel.rs +++ b/bgp/src/connection_channel.rs @@ -17,20 +17,23 @@ use crate::{ error::Error, log::{connection_log, connection_log_lite}, messages::Message, - session::{ConnectionEvent, FsmEvent, SessionEndpoint, SessionInfo}, + session::{ + ConnectionEvent, FsmEvent, PeerId, SessionEndpoint, SessionInfo, + }, + unnumbered::UnnumberedManager, }; use mg_common::lock; use slog::Logger; use std::{ collections::{BTreeMap, HashMap}, - net::{IpAddr, SocketAddr, ToSocketAddrs}, + net::{SocketAddr, ToSocketAddrs}, sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, mpsc::{Receiver, RecvTimeoutError, Sender, channel as mpsc_channel}, }, thread::{JoinHandle, spawn}, - time::Duration, + time::{Duration, Instant}, }; const UNIT_CONNECTION: &str = "connection_channel"; @@ -38,8 +41,24 @@ const UNIT_CONNECTION: &str = "connection_channel"; /// Global counter for assigning unique IDs to channel pairs static CHANNEL_PAIR_ID: AtomicU64 = AtomicU64::new(0); +/// Connection attempt record for testing. +/// Tracks all outbound connection attempts made via BgpConnectorChannel. +#[derive(Debug, Clone)] +pub struct ConnectionAttempt { + pub timestamp: Instant, + pub local: SocketAddr, + pub peer: SocketAddr, + pub success: bool, +} + lazy_static! { static ref NET: Network = Network::new(); + + /// Global tracker for connection attempts (for testing). + /// Records all outbound connection attempts so tests can verify + /// connection behavior (e.g., that unnumbered sessions only connect + /// when neighbors are discovered). + static ref CONNECTION_ATTEMPTS: Mutex> = Mutex::new(Vec::new()); } /// A simulated network that maps socket addresses to channels that can send @@ -118,14 +137,77 @@ impl Network { } } +// ========================================================================= +// Connection Attempt Tracking (for testing) +// ========================================================================= + +/// Get all recorded connection attempts. +pub fn get_connection_attempts() -> Vec { + lock!(CONNECTION_ATTEMPTS).clone() +} + +/// Clear all recorded connection attempts. +/// Useful for test setup/cleanup. +pub fn clear_connection_attempts() { + lock!(CONNECTION_ATTEMPTS).clear(); +} + +/// Get connection attempts to a specific peer address. +pub fn get_connection_attempts_to(peer: SocketAddr) -> Vec { + lock!(CONNECTION_ATTEMPTS) + .iter() + .filter(|attempt| attempt.peer == peer) + .cloned() + .collect() +} + +/// Get the number of successful connection attempts to a specific peer. +pub fn count_successful_connections_to(peer: SocketAddr) -> usize { + lock!(CONNECTION_ATTEMPTS) + .iter() + .filter(|attempt| attempt.peer == peer && attempt.success) + .count() +} + +/// Get the number of failed connection attempts to a specific peer. +pub fn count_failed_connections_to(peer: SocketAddr) -> usize { + lock!(CONNECTION_ATTEMPTS) + .iter() + .filter(|attempt| attempt.peer == peer && !attempt.success) + .count() +} + /// A struct to implement BgpListener for our simulated test network. pub struct BgpListenerChannel { listener: Listener, bind_addr: SocketAddr, + unnumbered_manager: Option>, +} + +impl BgpListenerChannel { + /// Resolve incoming peer address to appropriate PeerId. + fn resolve_session_key(&self, peer_addr: SocketAddr) -> PeerId { + // Try interface-based routing for IPv6 link-local addresses + if let Some(ref mgr) = self.unnumbered_manager + && let SocketAddr::V6(v6_addr) = peer_addr + && v6_addr.ip().is_unicast_link_local() + { + let scope_id = v6_addr.scope_id(); + if let Some(interface) = mgr.get_interface_for_scope(scope_id) { + return PeerId::Interface(interface); + } + } + + // Default to IP-based routing + PeerId::Ip(peer_addr.ip()) + } } impl BgpListener for BgpListenerChannel { - fn bind(addr: A) -> Result + fn bind( + addr: A, + unnumbered_manager: Option>, + ) -> Result where Self: Sized, { @@ -140,14 +222,15 @@ impl BgpListener for BgpListenerChannel { Ok(Self { listener, bind_addr: addr, + unnumbered_manager, }) } fn accept( &self, log: Logger, - addr_to_session: Arc< - Mutex>>, + peer_to_session: Arc< + Mutex>>, >, timeout: Duration, ) -> Result { @@ -159,7 +242,10 @@ impl BgpListener for BgpListenerChannel { // testing purposes with channels, the bind address is the connection address. let local = self.bind_addr; - match lock!(addr_to_session).get(&peer.ip()) { + // Resolve peer address to appropriate PeerId (IP or Interface) + let key = self.resolve_session_key(peer); + + match lock!(peer_to_session).get(&key) { Some(session_endpoint) => { let config = lock!(session_endpoint.config); Ok(BgpConnectionChannel::with_conn( @@ -488,7 +574,17 @@ impl BgpConnector for BgpConnectorChannel { // is synchronous. This allows SessionRunner to track the connector thread. let handle = spawn(move || { let (local, remote) = channel(); - match NET.connect(addr, peer, remote) { + + // Record connection attempt for testing + let attempt_result = NET.connect(addr, peer, remote); + lock!(CONNECTION_ATTEMPTS).push(ConnectionAttempt { + timestamp: Instant::now(), + local: addr, + peer, + success: attempt_result.is_ok(), + }); + + match attempt_result { Ok(()) => { let conn = BgpConnectionChannel::with_conn( addr, diff --git a/bgp/src/connection_tcp.rs b/bgp/src/connection_tcp.rs index c8274893..ae12c2f4 100644 --- a/bgp/src/connection_tcp.rs +++ b/bgp/src/connection_tcp.rs @@ -19,8 +19,10 @@ use crate::{ RouteRefreshParseError, RouteRefreshParseErrorReason, UpdateMessage, }, session::{ - ConnectionEvent, FsmEvent, SessionEndpoint, SessionEvent, SessionInfo, + ConnectionEvent, FsmEvent, PeerId, SessionEndpoint, SessionEvent, + SessionInfo, }, + unnumbered::UnnumberedManager, }; use mg_common::lock; use slog::Logger; @@ -28,7 +30,7 @@ use std::{ collections::BTreeMap, io::Read, io::Write, - net::{IpAddr, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}, + net::{SocketAddr, TcpListener, TcpStream, ToSocketAddrs}, sync::atomic::AtomicBool, sync::{Arc, Mutex, atomic::Ordering, mpsc::Sender}, thread::{JoinHandle, sleep}, @@ -49,7 +51,7 @@ use libc::{IP_MINTTL, TCP_MD5SIG, sockaddr_storage}; #[cfg(target_os = "illumos")] use itertools::Itertools; #[cfg(target_os = "illumos")] -use std::collections::HashSet; +use std::{collections::HashSet, net::IpAddr}; const UNIT_CONNECTION: &str = "connection_tcp"; @@ -73,10 +75,33 @@ enum RecvError { pub struct BgpListenerTcp { listener: TcpListener, + unnumbered_manager: Option>, +} + +impl BgpListenerTcp { + /// Resolve incoming peer address to appropriate PeerId. + fn resolve_session_key(&self, peer_addr: SocketAddr) -> PeerId { + // Try interface-based routing for IPv6 link-local addresses + if let Some(ref mgr) = self.unnumbered_manager + && let SocketAddr::V6(v6_addr) = peer_addr + && v6_addr.ip().is_unicast_link_local() + { + let scope_id = v6_addr.scope_id(); + if let Some(interface) = mgr.get_interface_for_scope(scope_id) { + return PeerId::Interface(interface); + } + } + + // Default to IP-based routing + PeerId::Ip(peer_addr.ip()) + } } impl BgpListener for BgpListenerTcp { - fn bind(addr: A) -> Result + fn bind( + addr: A, + unnumbered_manager: Option>, + ) -> Result where Self: Sized, { @@ -89,14 +114,17 @@ impl BgpListener for BgpListenerTcp { ))?; let listener = TcpListener::bind(addr)?; listener.set_nonblocking(true)?; - Ok(Self { listener }) + Ok(Self { + listener, + unnumbered_manager, + }) } fn accept( &self, log: Logger, - addr_to_session: Arc< - Mutex>>, + peer_to_session: Arc< + Mutex>>, >, timeout: Duration, ) -> Result { @@ -115,8 +143,11 @@ impl BgpListener for BgpListenerTcp { let mut local = conn.local_addr()?; local.set_ip(local.ip().to_canonical()); + // Resolve peer address to appropriate PeerId (IP or Interface) + let key = self.resolve_session_key(peer); + // Check if we have a session for this peer - match lock!(addr_to_session).get(&ip) { + match lock!(peer_to_session).get(&key) { Some(session_endpoint) => { let config = lock!(session_endpoint.config); return BgpConnectionTcp::with_conn( diff --git a/bgp/src/dispatcher.rs b/bgp/src/dispatcher.rs index 0a7c0ac2..3027d810 100644 --- a/bgp/src/dispatcher.rs +++ b/bgp/src/dispatcher.rs @@ -6,13 +6,14 @@ use crate::{ IO_TIMEOUT, connection::{BgpConnection, BgpListener}, log::dispatcher_log, - session::{FsmEvent, SessionEndpoint, SessionEvent}, + session::{FsmEvent, PeerId, SessionEndpoint, SessionEvent}, + unnumbered::UnnumberedManager, }; use mg_common::lock; use slog::Logger; use std::{ collections::BTreeMap, - net::IpAddr, + net::SocketAddr, sync::atomic::{AtomicBool, Ordering}, sync::{Arc, Mutex}, thread::sleep, @@ -22,7 +23,15 @@ use std::{ const UNIT_DISPATCHER: &str = "dispatcher"; pub struct Dispatcher { - pub addr_to_session: Arc>>>, + /// Session endpoint map indexed by PeerId (IP or interface name) + /// This unified map supports both numbered and unnumbered BGP sessions + pub peer_to_session: Arc>>>, + + /// Optional unnumbered neighbor manager for link-local connection routing. + /// When present, enables routing of IPv6 link-local connections to + /// unnumbered sessions based on interface scope_id + unnumbered_manager: Option>, + shutdown: AtomicBool, listen: String, log: Logger, @@ -30,18 +39,56 @@ pub struct Dispatcher { impl Dispatcher { pub fn new( - addr_to_session: Arc>>>, + peer_to_session: Arc>>>, listen: String, log: Logger, + unnumbered_manager: Option>, ) -> Self { Self { - addr_to_session, + peer_to_session, + unnumbered_manager, listen, log, shutdown: AtomicBool::new(false), } } + /// Resolve incoming peer address to appropriate PeerId. + /// + /// For IPv6 link-local addresses with an unnumbered manager, attempts to + /// resolve scope_id to interface name for interface-based routing. + /// Falls back to IP-based routing for all other cases. + fn resolve_session_key(&self, peer_addr: SocketAddr) -> PeerId { + // Try interface-based routing for IPv6 link-local addresses + if let Some(ref mgr) = self.unnumbered_manager + && let SocketAddr::V6(v6_addr) = peer_addr + && v6_addr.ip().is_unicast_link_local() + { + let scope_id = v6_addr.scope_id(); + if let Some(interface) = mgr.get_interface_for_scope(scope_id) { + dispatcher_log!(self, + debug, + "routing link-local connection to interface session"; + "peer" => format!("{}", peer_addr), + "scope_id" => scope_id, + "interface" => &interface, + "listen_address" => &self.listen + ); + return PeerId::Interface(interface); + } + dispatcher_log!(self, + debug, + "no interface mapping for link-local scope_id, using IP lookup"; + "peer" => format!("{}", peer_addr), + "scope_id" => scope_id, + "listen_address" => &self.listen + ); + } + + // Default to IP-based routing + PeerId::Ip(peer_addr.ip()) + } + pub fn run>(&self) { dispatcher_log!(self, info, @@ -65,7 +112,10 @@ impl Dispatcher { "listener bind: {}", &self.listen; "listen_address" => &self.listen ); - let listener = match Listener::bind(&self.listen) { + let listener = match Listener::bind( + &self.listen, + self.unnumbered_manager.clone(), + ) { Ok(l) => l, Err(e) => { dispatcher_log!(self, @@ -95,7 +145,7 @@ impl Dispatcher { let accepted = match listener.accept( self.log.clone(), - self.addr_to_session.clone(), + self.peer_to_session.clone(), IO_TIMEOUT, ) { Ok(c) => { @@ -119,8 +169,11 @@ impl Dispatcher { continue 'listener; } }; - let addr = accepted.peer().ip(); - match lock!(self.addr_to_session).get(&addr).cloned() { + + let peer_addr = accepted.peer(); + let key = self.resolve_session_key(peer_addr); + + match lock!(self.peer_to_session).get(&key).cloned() { Some(session_endpoint) => { // Apply connection policy from the session configuration let min_ttl = lock!(session_endpoint.config).min_ttl; @@ -132,9 +185,10 @@ impl Dispatcher { { dispatcher_log!(self, warn, - "failed to apply policy for connection from {addr}: {e}"; + "failed to apply policy for connection from {}: {e}", peer_addr; "listen_address" => &self.listen, - "address" => format!("{addr}"), + "peer" => format!("{}", peer_addr), + "session_key" => format!("{:?}", key), "error" => format!("{e}") ); } @@ -146,9 +200,10 @@ impl Dispatcher { { dispatcher_log!(self, error, - "failed to send connected event to session for {addr}: {e}"; + "failed to send connected event to session for {}: {e}", peer_addr; "listen_address" => &self.listen, - "address" => format!("{addr}") + "peer" => format!("{}", peer_addr), + "session_key" => format!("{:?}", key) ); continue 'listener; } diff --git a/bgp/src/error.rs b/bgp/src/error.rs index d739ecdc..eff442cb 100644 --- a/bgp/src/error.rs +++ b/bgp/src/error.rs @@ -157,7 +157,7 @@ pub enum Error { #[error("Enforce-first-AS check failed: expected: {0}, found: {1:?}")] EnforceAsFirst(u32, Vec), - #[error("Invalid address")] + #[error("Invalid address: {0}")] InvalidAddress(String), #[error("Datastore error: {0}")] diff --git a/bgp/src/fanout.rs b/bgp/src/fanout.rs index e6a4e68f..08dd7cda 100644 --- a/bgp/src/fanout.rs +++ b/bgp/src/fanout.rs @@ -4,14 +4,13 @@ use crate::connection::BgpConnection; use crate::session::{ - AdminEvent, FsmEvent, RouteUpdate, RouteUpdate4, RouteUpdate6, + AdminEvent, FsmEvent, PeerId, RouteUpdate, RouteUpdate4, RouteUpdate6, }; use crate::{COMPONENT_BGP, MOD_NEIGHBOR}; use rdb::types::{Ipv4Marker, Ipv6Marker, Prefix4, Prefix6}; use slog::Logger; use std::collections::BTreeMap; use std::marker::PhantomData; -use std::net::IpAddr; use std::sync::mpsc::Sender; const UNIT_FANOUT: &str = "fanout"; @@ -26,8 +25,8 @@ pub type Fanout6 = Fanout; /// - Fanout4 (Fanout<_, Ipv4Marker>) only accepts Prefix4 /// - Fanout6 (Fanout<_, Ipv6Marker>) only accepts Prefix6 pub struct Fanout { - /// Indexed neighbor address - egress: BTreeMap>, + /// Indexed by peer identifier (IP address or interface name) + egress: BTreeMap>, /// Zero-sized marker for address family type enforcement _af: PhantomData, } @@ -68,12 +67,12 @@ impl Fanout { /// Announce and/or withdraw IPv4 routes to all peers except the origin. pub fn send_except( &self, - origin: IpAddr, + origin: &PeerId, nlri: Vec, withdrawn: Vec, ) { - for (peer_addr, egress) in &self.egress { - if *peer_addr == origin { + for (peer_id, egress) in &self.egress { + if peer_id == origin { continue; } if !nlri.is_empty() { @@ -111,12 +110,12 @@ impl Fanout { /// Announce and/or withdraw IPv6 routes to all peers except the origin. pub fn send_except( &self, - origin: IpAddr, + origin: &PeerId, nlri: Vec, withdrawn: Vec, ) { - for (peer_addr, egress) in &self.egress { - if *peer_addr == origin { + for (peer_id, egress) in &self.egress { + if peer_id == origin { continue; } if !nlri.is_empty() { @@ -135,12 +134,12 @@ impl Fanout { // Common methods available for all address families impl Fanout { - pub fn add_egress(&mut self, peer: IpAddr, egress: Egress) { + pub fn add_egress(&mut self, peer: PeerId, egress: Egress) { self.egress.insert(peer, egress); } - pub fn remove_egress(&mut self, peer: IpAddr) { - self.egress.remove(&peer); + pub fn remove_egress(&mut self, peer: &PeerId) { + self.egress.remove(peer); } pub fn is_empty(&self) -> bool { diff --git a/bgp/src/lib.rs b/bgp/src/lib.rs index d7b59c3f..095d469f 100644 --- a/bgp/src/lib.rs +++ b/bgp/src/lib.rs @@ -15,6 +15,7 @@ pub mod params; pub mod policy; pub mod router; pub mod session; +pub mod unnumbered; mod rhai_integration; @@ -31,6 +32,9 @@ mod test; #[cfg(test)] pub mod connection_channel; +#[cfg(test)] +pub mod unnumbered_mock; + pub const BGP_PORT: u16 = 179; pub const BGP_VERSION: u8 = 4; pub const COMPONENT_BGP: &str = "bgp"; diff --git a/bgp/src/log.rs b/bgp/src/log.rs index 878c76d5..fb9f4114 100644 --- a/bgp/src/log.rs +++ b/bgp/src/log.rs @@ -10,7 +10,7 @@ macro_rules! session_log { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "connection" => format!("{:?}", $conn.conn()), "connection_id" => $conn.id().short(), @@ -27,7 +27,7 @@ macro_rules! session_log { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "connection" => format!("{:?}", $conn.conn()), "connection_id" => $conn.id().short(), @@ -44,7 +44,7 @@ macro_rules! session_log { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "connection" => format!("{:?}", $conn.conn()), "connection_id" => $conn.id().short(), @@ -60,7 +60,7 @@ macro_rules! session_log { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "connection" => format!("{:?}", $conn.conn()), "connection_id" => $conn.id().short(), @@ -80,7 +80,7 @@ macro_rules! session_log_lite { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "fsm_state" => $self.state().as_str(), $($key => $value),* @@ -93,7 +93,7 @@ macro_rules! session_log_lite { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "fsm_state" => $self.state().as_str(), $($key => $value),* @@ -106,7 +106,7 @@ macro_rules! session_log_lite { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "fsm_state" => $self.state().as_str(), ) @@ -118,7 +118,7 @@ macro_rules! session_log_lite { "module" => crate::MOD_NEIGHBOR, "unit" => UNIT_SESSION_RUNNER, "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "fsm_state" => $self.state().as_str(), ) @@ -134,7 +134,7 @@ macro_rules! collision_log { "unit" => UNIT_SESSION_RUNNER, "fsm_state" => $self.state().as_str(), "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "new_conn" => format!("{:?}", $new.conn()), "new_conn_id" => $new.id().short(), @@ -155,7 +155,7 @@ macro_rules! collision_log { "unit" => UNIT_SESSION_RUNNER, "fsm_state" => $self.state().as_str(), "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "new_conn" => format!("{:?}", $new.conn()), "new_conn_id" => $new.id().short(), @@ -176,7 +176,7 @@ macro_rules! collision_log { "unit" => UNIT_SESSION_RUNNER, "fsm_state" => $self.state().as_str(), "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "new_conn" => format!("{:?}", $new.conn()), "new_conn_id" => $new.id().short(), @@ -196,7 +196,7 @@ macro_rules! collision_log { "unit" => UNIT_SESSION_RUNNER, "fsm_state" => $self.state().as_str(), "neighbor_name" => lock!($self.neighbor.name).as_str(), - "neighbor" => format!("{}", $self.neighbor.host.ip()), + "neighbor" => format!("{}", $self.neighbor.peer), "session_clock" => format!("{}", $self.clock), "new_conn" => format!("{:?}", $new.conn()), "new_conn_id" => $new.id().short(), diff --git a/bgp/src/messages.rs b/bgp/src/messages.rs index c6a6bf23..c278910a 100644 --- a/bgp/src/messages.rs +++ b/bgp/src/messages.rs @@ -575,26 +575,56 @@ pub struct OpenMessage { impl OpenMessage { /// Create a new open message for a sender with a 2-byte ASN - pub fn new2(asn: u16, hold_time: u16, id: u32) -> OpenMessage { + pub fn new2( + asn: u16, + hold_time: u16, + id: u32, + extended_nexthop: bool, + ) -> OpenMessage { + let parameters = if extended_nexthop { + let caps = BTreeSet::from([Capability::ExtendedNextHopEncoding { + elements: vec![ExtendedNexthopElement { + afi: Afi::Ipv4.into(), + safi: u8::from(Safi::Unicast).into(), + nh_afi: Afi::Ipv6.into(), + }], + }]); + vec![OptionalParameter::Capabilities(caps)] + } else { + Vec::default() + }; OpenMessage { version: BGP4, asn, hold_time, id, - parameters: Vec::new(), + parameters, } } /// Create a new open message for a sender with a 4-byte ASN - pub fn new4(asn: u32, hold_time: u16, id: u32) -> OpenMessage { + pub fn new4( + asn: u32, + hold_time: u16, + id: u32, + extended_nexthop: bool, + ) -> OpenMessage { + let mut params = BTreeSet::from([Capability::FourOctetAs { asn }]); + if extended_nexthop { + params.insert(Capability::ExtendedNextHopEncoding { + elements: vec![ExtendedNexthopElement { + afi: Afi::Ipv4.into(), + safi: u8::from(Safi::Unicast).into(), + nh_afi: Afi::Ipv6.into(), + }], + }); + } OpenMessage { version: BGP4, asn: u16::try_from(asn).unwrap_or(AS_TRANS), hold_time, id, - parameters: vec![OptionalParameter::Capabilities(BTreeSet::from( - [Capability::FourOctetAs { asn }], - ))], + parameters: vec![OptionalParameter::Capabilities(params)], } } @@ -613,12 +643,13 @@ impl OpenMessage { } pub fn get_capabilities(&self) -> BTreeSet { + let mut result = BTreeSet::new(); for p in self.parameters.iter() { if let OptionalParameter::Capabilities(caps) = p { - return caps.clone(); + result.extend(caps.clone().into_iter()); } } - BTreeSet::new() + result } pub fn has_capability(&self, code: CapabilityCode) -> bool { @@ -2818,19 +2849,18 @@ impl BgpNexthop { // SAFETY: The length check above guarantees nh_bytes.len() == nh_len. // Each match arm below only matches when nh_len equals the exact size // needed for copy_from_slice, so all slice operations are bounds-safe. - // XXX: extended nexthop support match (afi, nh_len) { (Afi::Ipv4, 4) => { let mut bytes = [0u8; 4]; bytes.copy_from_slice(nh_bytes); Ok(BgpNexthop::Ipv4(Ipv4Addr::from(bytes))) } - (Afi::Ipv6, 16) => { + (Afi::Ipv4 | Afi::Ipv6, 16) => { let mut bytes = [0u8; 16]; bytes.copy_from_slice(nh_bytes); Ok(BgpNexthop::Ipv6Single(Ipv6Addr::from(bytes))) } - (Afi::Ipv6, 32) => { + (Afi::Ipv4 | Afi::Ipv6, 32) => { let mut bytes1 = [0u8; 16]; let mut bytes2 = [0u8; 16]; bytes1.copy_from_slice(&nh_bytes[..16]); @@ -4090,7 +4120,60 @@ impl Display for AddPathElement { write!( f, "AddPathElement {{ afi: {}, safi: {}, send_receive: {} }}", - self.afi, self.safi, self.send_receive + match Afi::try_from_primitive(self.afi) { + Ok(x) => x.to_string(), + _ => self.afi.to_string(), + }, + match Safi::try_from_primitive(self.safi) { + Ok(x) => x.to_string(), + _ => self.safi.to_string(), + }, + self.send_receive + ) + } +} + +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + Serialize, + Deserialize, + JsonSchema, + PartialOrd, + Ord, +)] +pub struct ExtendedNexthopElement { + pub afi: u16, + pub safi: u16, + pub nh_afi: u16, +} + +impl ExtendedNexthopElement { + fn is_v4_over_v6(&self) -> bool { + self == &ExtendedNexthopElement { + afi: Afi::Ipv4.into(), + safi: u8::from(Safi::Unicast).into(), + nh_afi: Afi::Ipv6.into(), + } + } + fn is_v6_over_v4(&self) -> bool { + self == &ExtendedNexthopElement { + afi: Afi::Ipv6.into(), + safi: u8::from(Safi::Unicast).into(), + nh_afi: Afi::Ipv4.into(), + } + } +} + +impl Display for ExtendedNexthopElement { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "safi={}/afi={}/nh_afi={}", + self.afi, self.safi, self.nh_afi ) } } @@ -4131,10 +4214,13 @@ pub enum Capability { /// (deprecated). Note this capability is not yet implemented. MultipleRoutesToDestination {}, - //TODO - /// Multiple nexthop encoding capability as defined in RFC 8950. Note this - /// capability is not yet implemented. - ExtendedNextHopEncoding {}, + /// Multiple nexthop encoding capability as defined in RFC 8950. + ExtendedNextHopEncoding { + //XXX trying to avoid a version bump on 86 billion data structures + // right now. + #[schemars(skip)] + elements: Vec, + }, //TODO /// Extended message capability as defined in RFC 8654. Note this @@ -4262,8 +4348,13 @@ impl Display for Capability { Capability::MultipleRoutesToDestination {} => { write!(f, "Multiple Routes to Destination") } - Capability::ExtendedNextHopEncoding {} => { - write!(f, "Extended Next Hop Encoding") + Capability::ExtendedNextHopEncoding { elements } => { + let elements = elements + .iter() + .map(|x| x.to_string()) + .collect::>() + .join(", "); + write!(f, "Extended Next Hop Encoding {elements}") } Capability::BGPExtendedMessage {} => { write!(f, "BGP Extended Message") @@ -4384,6 +4475,18 @@ impl Capability { let buf = vec![CapabilityCode::EnhancedRouteRefresh.into(), 0]; Ok(buf) } + Self::ExtendedNextHopEncoding { elements } => { + let mut buf = vec![ + CapabilityCode::ExtendedNextHopEncoding as u8, + (elements.len() * 6) as u8, + ]; + for e in elements { + buf.extend_from_slice(&e.afi.to_be_bytes()); + buf.extend_from_slice(&e.safi.to_be_bytes()); + buf.extend_from_slice(&e.nh_afi.to_be_bytes()); + } + Ok(buf) + } Self::Experimental { code: _ } => Err(Error::Experimental), Self::Unassigned { code } => Err(Error::Unassigned(*code)), Self::Reserved { code: _ } => Err(Error::ReservedCapability), @@ -4409,303 +4512,282 @@ impl Capability { return Ok((&input[len..], Capability::Unassigned { code })); } }; - let mut input = input; + let (cap_data, remaining) = input.split_at(len); + let mut input = cap_data; - match code { + let cap = match code { CapabilityCode::MultiprotocolExtensions => { let (input, afi) = be_u16(input)?; let (input, _) = be_u8(input)?; - let (input, safi) = be_u8(input)?; - Ok((input, Capability::MultiprotocolExtensions { afi, safi })) + let (_, safi) = be_u8(input)?; + Capability::MultiprotocolExtensions { afi, safi } } - CapabilityCode::RouteRefresh => { - Ok((&input[len..], Capability::RouteRefresh {})) - } - + CapabilityCode::RouteRefresh => Capability::RouteRefresh {}, CapabilityCode::GracefulRestart => { //TODO handle for real - Ok((&input[len..], Capability::GracefulRestart {})) + Capability::GracefulRestart {} } CapabilityCode::FourOctetAs => { - let (input, asn) = be_u32(input)?; - Ok((input, Capability::FourOctetAs { asn })) + let (_, asn) = be_u32(input)?; + Capability::FourOctetAs { asn } } CapabilityCode::AddPath => { let mut elements = BTreeSet::new(); while !input.is_empty() { - let (remaining, afi) = be_u16(input)?; - let (remaining, safi) = be_u8(remaining)?; - let (remaining, send_receive) = be_u8(remaining)?; + let (rem, afi) = be_u16(input)?; + let (rem, safi) = be_u8(rem)?; + let (rem, send_receive) = be_u8(rem)?; elements.insert(AddPathElement { afi, safi, send_receive, }); - input = remaining; + input = rem; } - Ok((input, Capability::AddPath { elements })) + Capability::AddPath { elements } } CapabilityCode::EnhancedRouteRefresh => { //TODO handle for real - Ok((&input[len..], Capability::EnhancedRouteRefresh {})) + Capability::EnhancedRouteRefresh {} } - CapabilityCode::Fqdn => { //TODO handle for real - Ok((&input[len..], Capability::Fqdn {})) + Capability::Fqdn {} } - CapabilityCode::PrestandardRouteRefresh => { //TODO handle for real - Ok((&input[len..], Capability::PrestandardRouteRefresh {})) + Capability::PrestandardRouteRefresh {} } - CapabilityCode::BGPExtendedMessage => { //TODO handle for real - Ok((&input[len..], Capability::BGPExtendedMessage {})) + Capability::BGPExtendedMessage {} } - CapabilityCode::LongLivedGracefulRestart => { //TODO handle for real - Ok((&input[len..], Capability::LongLivedGracefulRestart {})) + Capability::LongLivedGracefulRestart {} } - CapabilityCode::MultipleRoutesToDestination => { //TODO handle for real - Ok((&input[len..], Capability::MultipleRoutesToDestination {})) + Capability::MultipleRoutesToDestination {} } - CapabilityCode::ExtendedNextHopEncoding => { - //TODO handle for real - Ok((&input[len..], Capability::ExtendedNextHopEncoding {})) + let mut elements = Vec::new(); + while !input.is_empty() { + let (rem, afi) = be_u16(input)?; + let (rem, safi) = be_u16(rem)?; + let (rem, nh_afi) = be_u16(rem)?; + elements.push(ExtendedNexthopElement { afi, safi, nh_afi }); + input = rem; + } + Capability::ExtendedNextHopEncoding { elements } } - CapabilityCode::OutboundRouteFiltering => { //TODO handle for real - Ok((&input[len..], Capability::OutboundRouteFiltering {})) + Capability::OutboundRouteFiltering {} } - CapabilityCode::BgpSec => { //TODO handle for real - Ok((&input[len..], Capability::BgpSec {})) + Capability::BgpSec {} } - CapabilityCode::MultipleLabels => { //TODO handle for real - Ok((&input[len..], Capability::MultipleLabels {})) + Capability::MultipleLabels {} } - CapabilityCode::BgpRole => { //TODO handle for real - Ok((&input[len..], Capability::BgpRole {})) + Capability::BgpRole {} } - CapabilityCode::DynamicCapability => { //TODO handle for real - Ok((&input[len..], Capability::DynamicCapability {})) + Capability::DynamicCapability {} } - CapabilityCode::MultisessionBgp => { //TODO handle for real - Ok((&input[len..], Capability::MultisessionBgp {})) + Capability::MultisessionBgp {} } - CapabilityCode::RoutingPolicyDistribution => { //TODO handle for real - Ok((&input[len..], Capability::RoutingPolicyDistribution {})) + Capability::RoutingPolicyDistribution {} } - CapabilityCode::PrestandardOrfAndPd => { //TODO handle for real - Ok((&input[len..], Capability::PrestandardOrfAndPd {})) + Capability::PrestandardOrfAndPd {} } - CapabilityCode::PrestandardOutboundRouteFiltering => { //TODO handle for real - Ok(( - &input[len..], - Capability::PrestandardOutboundRouteFiltering {}, - )) + Capability::PrestandardOutboundRouteFiltering {} } - CapabilityCode::PrestandardMultisession => { //TODO handle for real - Ok((&input[len..], Capability::PrestandardMultisession {})) + Capability::PrestandardMultisession {} } - CapabilityCode::PrestandardFqdn => { //TODO handle for real - Ok((&input[len..], Capability::PrestandardFqdn {})) + Capability::PrestandardFqdn {} } - CapabilityCode::PrestandardOperationalMessage => { //TODO handle for real - Ok(( - &input[len..], - Capability::PrestandardOperationalMessage {}, - )) + Capability::PrestandardOperationalMessage {} } - CapabilityCode::Experimental0 => { - Ok((&input[len..], Capability::Experimental { code: 0 })) + Capability::Experimental { code: 0 } } CapabilityCode::Experimental1 => { - Ok((&input[len..], Capability::Experimental { code: 1 })) + Capability::Experimental { code: 1 } } CapabilityCode::Experimental2 => { - Ok((&input[len..], Capability::Experimental { code: 2 })) + Capability::Experimental { code: 2 } } CapabilityCode::Experimental3 => { - Ok((&input[len..], Capability::Experimental { code: 3 })) + Capability::Experimental { code: 3 } } CapabilityCode::Experimental4 => { - Ok((&input[len..], Capability::Experimental { code: 4 })) + Capability::Experimental { code: 4 } } CapabilityCode::Experimental5 => { - Ok((&input[len..], Capability::Experimental { code: 5 })) + Capability::Experimental { code: 5 } } CapabilityCode::Experimental6 => { - Ok((&input[len..], Capability::Experimental { code: 6 })) + Capability::Experimental { code: 6 } } CapabilityCode::Experimental7 => { - Ok((&input[len..], Capability::Experimental { code: 7 })) + Capability::Experimental { code: 7 } } CapabilityCode::Experimental8 => { - Ok((&input[len..], Capability::Experimental { code: 8 })) + Capability::Experimental { code: 8 } } CapabilityCode::Experimental9 => { - Ok((&input[len..], Capability::Experimental { code: 9 })) + Capability::Experimental { code: 9 } } CapabilityCode::Experimental10 => { - Ok((&input[len..], Capability::Experimental { code: 10 })) + Capability::Experimental { code: 10 } } CapabilityCode::Experimental11 => { - Ok((&input[len..], Capability::Experimental { code: 11 })) + Capability::Experimental { code: 11 } } CapabilityCode::Experimental12 => { - Ok((&input[len..], Capability::Experimental { code: 12 })) + Capability::Experimental { code: 12 } } CapabilityCode::Experimental13 => { - Ok((&input[len..], Capability::Experimental { code: 13 })) + Capability::Experimental { code: 13 } } CapabilityCode::Experimental14 => { - Ok((&input[len..], Capability::Experimental { code: 14 })) + Capability::Experimental { code: 14 } } CapabilityCode::Experimental15 => { - Ok((&input[len..], Capability::Experimental { code: 15 })) + Capability::Experimental { code: 15 } } CapabilityCode::Experimental16 => { - Ok((&input[len..], Capability::Experimental { code: 16 })) + Capability::Experimental { code: 16 } } CapabilityCode::Experimental17 => { - Ok((&input[len..], Capability::Experimental { code: 17 })) + Capability::Experimental { code: 17 } } CapabilityCode::Experimental18 => { - Ok((&input[len..], Capability::Experimental { code: 18 })) + Capability::Experimental { code: 18 } } CapabilityCode::Experimental19 => { - Ok((&input[len..], Capability::Experimental { code: 19 })) + Capability::Experimental { code: 19 } } CapabilityCode::Experimental20 => { - Ok((&input[len..], Capability::Experimental { code: 20 })) + Capability::Experimental { code: 20 } } CapabilityCode::Experimental21 => { - Ok((&input[len..], Capability::Experimental { code: 21 })) + Capability::Experimental { code: 21 } } CapabilityCode::Experimental22 => { - Ok((&input[len..], Capability::Experimental { code: 22 })) + Capability::Experimental { code: 22 } } CapabilityCode::Experimental23 => { - Ok((&input[len..], Capability::Experimental { code: 23 })) + Capability::Experimental { code: 23 } } CapabilityCode::Experimental24 => { - Ok((&input[len..], Capability::Experimental { code: 24 })) + Capability::Experimental { code: 24 } } CapabilityCode::Experimental25 => { - Ok((&input[len..], Capability::Experimental { code: 25 })) + Capability::Experimental { code: 25 } } CapabilityCode::Experimental26 => { - Ok((&input[len..], Capability::Experimental { code: 26 })) + Capability::Experimental { code: 26 } } CapabilityCode::Experimental27 => { - Ok((&input[len..], Capability::Experimental { code: 27 })) + Capability::Experimental { code: 27 } } CapabilityCode::Experimental28 => { - Ok((&input[len..], Capability::Experimental { code: 28 })) + Capability::Experimental { code: 28 } } CapabilityCode::Experimental29 => { - Ok((&input[len..], Capability::Experimental { code: 29 })) + Capability::Experimental { code: 29 } } CapabilityCode::Experimental30 => { - Ok((&input[len..], Capability::Experimental { code: 30 })) + Capability::Experimental { code: 30 } } CapabilityCode::Experimental31 => { - Ok((&input[len..], Capability::Experimental { code: 31 })) + Capability::Experimental { code: 31 } } CapabilityCode::Experimental32 => { - Ok((&input[len..], Capability::Experimental { code: 32 })) + Capability::Experimental { code: 32 } } CapabilityCode::Experimental33 => { - Ok((&input[len..], Capability::Experimental { code: 33 })) + Capability::Experimental { code: 33 } } CapabilityCode::Experimental34 => { - Ok((&input[len..], Capability::Experimental { code: 34 })) + Capability::Experimental { code: 34 } } CapabilityCode::Experimental35 => { - Ok((&input[len..], Capability::Experimental { code: 35 })) + Capability::Experimental { code: 35 } } CapabilityCode::Experimental36 => { - Ok((&input[len..], Capability::Experimental { code: 36 })) + Capability::Experimental { code: 36 } } CapabilityCode::Experimental37 => { - Ok((&input[len..], Capability::Experimental { code: 37 })) + Capability::Experimental { code: 37 } } CapabilityCode::Experimental38 => { - Ok((&input[len..], Capability::Experimental { code: 38 })) + Capability::Experimental { code: 38 } } CapabilityCode::Experimental39 => { - Ok((&input[len..], Capability::Experimental { code: 39 })) + Capability::Experimental { code: 39 } } CapabilityCode::Experimental40 => { - Ok((&input[len..], Capability::Experimental { code: 40 })) + Capability::Experimental { code: 40 } } CapabilityCode::Experimental41 => { - Ok((&input[len..], Capability::Experimental { code: 41 })) + Capability::Experimental { code: 41 } } CapabilityCode::Experimental42 => { - Ok((&input[len..], Capability::Experimental { code: 42 })) + Capability::Experimental { code: 42 } } CapabilityCode::Experimental43 => { - Ok((&input[len..], Capability::Experimental { code: 43 })) + Capability::Experimental { code: 43 } } CapabilityCode::Experimental44 => { - Ok((&input[len..], Capability::Experimental { code: 44 })) + Capability::Experimental { code: 44 } } CapabilityCode::Experimental45 => { - Ok((&input[len..], Capability::Experimental { code: 45 })) + Capability::Experimental { code: 45 } } CapabilityCode::Experimental46 => { - Ok((&input[len..], Capability::Experimental { code: 46 })) + Capability::Experimental { code: 46 } } CapabilityCode::Experimental47 => { - Ok((&input[len..], Capability::Experimental { code: 47 })) + Capability::Experimental { code: 47 } } CapabilityCode::Experimental48 => { - Ok((&input[len..], Capability::Experimental { code: 48 })) + Capability::Experimental { code: 48 } } CapabilityCode::Experimental49 => { - Ok((&input[len..], Capability::Experimental { code: 49 })) + Capability::Experimental { code: 49 } } CapabilityCode::Experimental50 => { - Ok((&input[len..], Capability::Experimental { code: 50 })) + Capability::Experimental { code: 50 } } CapabilityCode::Experimental51 => { - Ok((&input[len..], Capability::Experimental { code: 51 })) - } - CapabilityCode::Reserved => { - Ok((&input[len..], Capability::Reserved { code: 0 })) + Capability::Experimental { code: 51 } } - } + CapabilityCode::Reserved => Capability::Reserved { code: 0 }, + }; + Ok((remaining, cap)) } /// Helper function to generate an IPv4 Unicast MP-BGP capability. @@ -4723,6 +4805,22 @@ impl Capability { safi: Safi::Unicast.into(), } } + + pub fn extended_nh_v4_over_v6(&self) -> bool { + if let Self::ExtendedNextHopEncoding { elements } = self { + elements.iter().any(|x| x.is_v4_over_v6()) + } else { + false + } + } + + pub fn extended_nh_v6_over_v4(&self) -> bool { + if let Self::ExtendedNextHopEncoding { elements } = self { + elements.iter().any(|x| x.is_v6_over_v4()) + } else { + false + } + } } /// The set of capability codes supported by this BGP implementation @@ -4874,7 +4972,7 @@ impl From for CapabilityCode { Capability::MultipleRoutesToDestination {} => { CapabilityCode::MultipleRoutesToDestination } - Capability::ExtendedNextHopEncoding {} => { + Capability::ExtendedNextHopEncoding { elements: _ } => { CapabilityCode::ExtendedNextHopEncoding } Capability::BGPExtendedMessage {} => { @@ -5902,7 +6000,18 @@ mod tests { #[test] fn open_round_trip() { - let om0 = OpenMessage::new4(395849, 0x1234, 0xaabbccdd); + let om0 = OpenMessage::new4(395849, 0x1234, 0xaabbccdd, false); + + let buf = om0.to_wire().expect("open message to wire"); + println!("buf: {}", buf.hex_dump()); + + let om1 = OpenMessage::from_wire(&buf).expect("open message from wire"); + assert_eq!(om0, om1); + } + + #[test] + fn open_round_trip_extended_nexthop() { + let om0 = OpenMessage::new4(395849, 0x1234, 0xaabbccdd, true); let buf = om0.to_wire().expect("open message to wire"); println!("buf: {}", buf.hex_dump()); diff --git a/bgp/src/params.rs b/bgp/src/params.rs index 2a038c43..9abf7a92 100644 --- a/bgp/src/params.rs +++ b/bgp/src/params.rs @@ -15,7 +15,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap}, - net::{IpAddr, SocketAddr}, + net::{IpAddr, SocketAddr, SocketAddrV6}, sync::atomic::Ordering, time::Duration, }; @@ -158,83 +158,22 @@ pub struct Ipv6UnicastConfig { pub struct Neighbor { pub asn: u32, pub name: String, - pub host: SocketAddr, - pub hold_time: u64, - pub idle_hold_time: u64, - pub delay_open: u64, - pub connect_retry: u64, - pub keepalive: u64, - pub resolution: u64, pub group: String, - pub passive: bool, - pub remote_asn: Option, - pub min_ttl: Option, - pub md5_auth_key: Option, - pub multi_exit_discriminator: Option, - pub communities: Vec, - pub local_pref: Option, - pub enforce_first_as: bool, - /// IPv4 Unicast address family configuration (None = disabled) - pub ipv4_unicast: Option, - /// IPv6 Unicast address family configuration (None = disabled) - pub ipv6_unicast: Option, - pub vlan_id: Option, - pub connect_retry_jitter: Option, - pub idle_hold_jitter: Option, - pub deterministic_collision_resolution: bool, + pub host: SocketAddr, + #[serde(flatten)] + pub parameters: BgpPeerParameters, } impl Neighbor { /// Validate that at least one address family is enabled pub fn validate_address_families(&self) -> Result<(), String> { - if self.ipv4_unicast.is_none() && self.ipv6_unicast.is_none() { + if self.parameters.ipv4_unicast.is_none() + && self.parameters.ipv6_unicast.is_none() + { return Err("at least one address family must be enabled".into()); } Ok(()) } - - /// Validate nexthop address family matches configured address families. - /// Initially strict: IPv4 nexthop requires IPv4, IPv6 requires IPv6. - /// Can be relaxed in future for Extended Next-Hop (RFC 5549). - /// - /// Additionally validates cross-AF scenarios: - /// - IPv4 Unicast enabled for IPv6 peer requires configured IPv4 nexthop - /// - IPv6 Unicast enabled for IPv4 peer requires configured IPv6 nexthop - pub fn validate_nexthop(&self) -> Result<(), String> { - if let Some(cfg) = &self.ipv4_unicast { - if let Some(nh) = cfg.nexthop { - if !nh.is_ipv4() { - return Err(format!( - "IPv4 unicast nexthop must be IPv4 address, got {}", - nh - )); - } - } else if self.host.is_ipv6() { - return Err( - "IPv4 Unicast enabled for IPv6 peer requires configured IPv4 nexthop" - .into(), - ); - } - } - - if let Some(cfg) = &self.ipv6_unicast { - if let Some(nh) = cfg.nexthop { - if !nh.is_ipv6() { - return Err(format!( - "IPv6 unicast nexthop must be IPv6 address, got {}", - nh - )); - } - } else if !self.host.is_ipv6() { - return Err( - "IPv6 Unicast enabled for IPv4 peer requires configured IPv6 nexthop" - .into(), - ); - } - } - - Ok(()) - } } /// Legacy neighbor configuration (v1/v2 API compatibility) @@ -242,25 +181,21 @@ impl Neighbor { pub struct NeighborV1 { pub asn: u32, pub name: String, + pub group: String, pub host: SocketAddr, - pub hold_time: u64, - pub idle_hold_time: u64, - pub delay_open: u64, - pub connect_retry: u64, - pub keepalive: u64, - pub resolution: u64, + #[serde(flatten)] + pub parameters: BgpPeerParametersV1, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct UnnumberedNeighbor { + pub asn: u32, + pub name: String, pub group: String, - pub passive: bool, - pub remote_asn: Option, - pub min_ttl: Option, - pub md5_auth_key: Option, - pub multi_exit_discriminator: Option, - pub communities: Vec, - pub local_pref: Option, - pub enforce_first_as: bool, - pub allow_import: ImportExportPolicyV1, - pub allow_export: ImportExportPolicyV1, - pub vlan_id: Option, + pub interface: String, + pub act_as_a_default_ipv6_router: u16, + #[serde(flatten)] + pub parameters: BgpPeerParameters, } impl From for PeerConfig { @@ -269,12 +204,12 @@ impl From for PeerConfig { name: rq.name.clone(), group: rq.group.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, } } } @@ -285,12 +220,12 @@ impl From for PeerConfig { name: rq.name.clone(), group: rq.group.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, } } } @@ -303,59 +238,117 @@ impl NeighborV1 { ) -> Self { Self { asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, - name: rq.name.clone(), - host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, - passive: rq.passive, group: group.clone(), - md5_auth_key: rq.md5_auth_key, - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities, - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - allow_import: rq.allow_import, - allow_export: rq.allow_export, - vlan_id: rq.vlan_id, + host: rq.host, + name: rq.name.clone(), + parameters: rq.parameters.clone(), } } pub fn from_rdb_neighbor_info(asn: u32, rq: &rdb::BgpNeighborInfo) -> Self { Self { asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, + group: rq.group.clone(), name: rq.name.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, - passive: rq.passive, + parameters: BgpPeerParametersV1 { + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + passive: rq.parameters.passive, + md5_auth_key: rq.parameters.md5_auth_key.clone(), + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities.clone(), + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + allow_import: ImportExportPolicyV1::from_per_af_policies( + &rq.parameters.allow_import4, + &rq.parameters.allow_import6, + ), + allow_export: ImportExportPolicyV1::from_per_af_policies( + &rq.parameters.allow_export4, + &rq.parameters.allow_export6, + ), + vlan_id: rq.parameters.vlan_id, + }, + } + } +} + +impl UnnumberedNeighbor { + pub fn from_bgp_peer_config( + asn: u32, + group: String, + rq: UnnumberedBgpPeerConfig, + ) -> Self { + Self { + asn, + group: group.clone(), + interface: rq.interface.clone(), + name: rq.name.clone(), + act_as_a_default_ipv6_router: rq.router_lifetime, + parameters: rq.parameters.clone(), + } + } + + pub fn to_peer_config(&self, addr: SocketAddrV6) -> PeerConfig { + PeerConfig { + name: self.name.clone(), + host: addr.into(), + group: self.group.clone(), + hold_time: self.parameters.hold_time, + idle_hold_time: self.parameters.idle_hold_time, + delay_open: self.parameters.delay_open, + connect_retry: self.parameters.connect_retry, + keepalive: self.parameters.keepalive, + resolution: self.parameters.resolution, + } + } + + pub fn from_rdb_neighbor_info( + asn: u32, + rq: &rdb::BgpUnnumberedNeighborInfo, + ) -> Self { + Self { + asn, group: rq.group.clone(), - md5_auth_key: rq.md5_auth_key.clone(), - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities.clone(), - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - // Combine per-AF policies into legacy format for API compatibility - allow_import: ImportExportPolicyV1::from_per_af_policies( - &rq.allow_import4, - &rq.allow_import6, - ), - allow_export: ImportExportPolicyV1::from_per_af_policies( - &rq.allow_export4, - &rq.allow_export6, - ), - vlan_id: rq.vlan_id, + name: rq.name.clone(), + interface: rq.interface.clone(), + act_as_a_default_ipv6_router: rq.router_lifetime, + parameters: BgpPeerParameters { + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + passive: rq.parameters.passive, + md5_auth_key: rq.parameters.md5_auth_key.clone(), + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities.clone(), + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + vlan_id: rq.parameters.vlan_id, + ipv4_unicast: None, + ipv6_unicast: None, + deterministic_collision_resolution: false, + idle_hold_jitter: None, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + }, } } } @@ -372,50 +365,30 @@ impl Neighbor { ) -> Self { Self { asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, name: rq.name.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, - passive: rq.passive, group: group.clone(), - md5_auth_key: rq.md5_auth_key, - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities, - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - ipv4_unicast: rq.ipv4_unicast, - ipv6_unicast: rq.ipv6_unicast, - vlan_id: rq.vlan_id, - connect_retry_jitter: rq.connect_retry_jitter, - idle_hold_jitter: rq.idle_hold_jitter, - deterministic_collision_resolution: rq - .deterministic_collision_resolution, + parameters: rq.parameters.clone(), } } pub fn from_rdb_neighbor_info(asn: u32, rq: &rdb::BgpNeighborInfo) -> Self { // Use explicit enablement flags from the database - let ipv4_unicast = if rq.ipv4_enabled { + let ipv4_unicast = if rq.parameters.ipv4_enabled { Some(Ipv4UnicastConfig { - nexthop: rq.nexthop4, - import_policy: rq.allow_import4.clone(), - export_policy: rq.allow_export4.clone(), + nexthop: rq.parameters.nexthop4, + import_policy: rq.parameters.allow_import4.clone(), + export_policy: rq.parameters.allow_export4.clone(), }) } else { None }; - let ipv6_unicast = if rq.ipv6_enabled { + let ipv6_unicast = if rq.parameters.ipv6_enabled { Some(Ipv6UnicastConfig { - nexthop: rq.nexthop6, - import_policy: rq.allow_import6.clone(), - export_policy: rq.allow_export6.clone(), + nexthop: rq.parameters.nexthop6, + import_policy: rq.parameters.allow_import6.clone(), + export_policy: rq.parameters.allow_export6.clone(), }) } else { None @@ -423,32 +396,36 @@ impl Neighbor { Self { asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, name: rq.name.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, - passive: rq.passive, group: rq.group.clone(), - md5_auth_key: rq.md5_auth_key.clone(), - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities.clone(), - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - ipv4_unicast, - ipv6_unicast, - vlan_id: rq.vlan_id, - connect_retry_jitter: Some(JitterRange { - min: 0.75, - max: 1.0, - }), - idle_hold_jitter: None, - deterministic_collision_resolution: false, + parameters: BgpPeerParameters { + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + passive: rq.parameters.passive, + md5_auth_key: rq.parameters.md5_auth_key.clone(), + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities.clone(), + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + ipv4_unicast, + ipv6_unicast, + vlan_id: rq.parameters.vlan_id, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + idle_hold_jitter: None, + deterministic_collision_resolution: false, + }, } } } @@ -848,6 +825,29 @@ pub struct ApplyRequestV1 { pub struct BgpPeerConfigV1 { pub host: SocketAddr, pub name: String, + #[serde(flatten)] + pub parameters: BgpPeerParametersV1, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct BgpPeerConfig { + pub host: SocketAddr, + pub name: String, + #[serde(flatten)] + pub parameters: BgpPeerParameters, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct UnnumberedBgpPeerConfig { + pub interface: String, + pub name: String, + pub router_lifetime: u16, + #[serde(flatten)] + pub parameters: BgpPeerParameters, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct BgpPeerParameters { pub hold_time: u64, pub idle_hold_time: u64, pub delay_open: u64, @@ -862,16 +862,30 @@ pub struct BgpPeerConfigV1 { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - pub allow_import: ImportExportPolicyV1, - pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, + + // new stuff after v1 + /// IPv4 Unicast address family configuration (None = disabled) + pub ipv4_unicast: Option, + /// IPv6 Unicast address family configuration (None = disabled) + pub ipv6_unicast: Option, + /// Enable deterministic collision resolution in Established state. + /// When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision + /// resolution even when one connection is already in Established state. + /// When false, Established connection always wins (timing-based resolution). + pub deterministic_collision_resolution: bool, + /// Jitter range for idle hold timer. When used, the idle hold timer is + /// multiplied by a random value within the (min, max) range supplied. + /// Useful to help break repeated synchronization of connection collisions. + pub idle_hold_jitter: Option, + /// Jitter range for connect_retry timer. When used, the connect_retry timer + /// is multiplied by a random value within the (min, max) range supplied. + /// Useful to help break repeated synchronization of connection collisions. + pub connect_retry_jitter: Option, } -/// BGP peer configuration (current version with per-address-family policies). -#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] -pub struct BgpPeerConfig { - pub host: SocketAddr, - pub name: String, +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, PartialEq)] +pub struct BgpPeerParametersV1 { pub hold_time: u64, pub idle_hold_time: u64, pub delay_open: u64, @@ -886,24 +900,9 @@ pub struct BgpPeerConfig { pub communities: Vec, pub local_pref: Option, pub enforce_first_as: bool, - /// IPv4 Unicast address family configuration (None = disabled) - pub ipv4_unicast: Option, - /// IPv6 Unicast address family configuration (None = disabled) - pub ipv6_unicast: Option, + pub allow_import: ImportExportPolicyV1, + pub allow_export: ImportExportPolicyV1, pub vlan_id: Option, - /// Jitter range for connect_retry timer. When used, the connect_retry timer - /// is multiplied by a random value within the (min, max) range supplied. - /// Useful to help break repeated synchronization of connection collisions. - pub connect_retry_jitter: Option, - /// Jitter range for idle hold timer. When used, the idle hold timer is - /// multiplied by a random value within the (min, max) range supplied. - /// Useful to help break repeated synchronization of connection collisions. - pub idle_hold_jitter: Option, - /// Enable deterministic collision resolution in Established state. - /// When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision - /// resolution even when one connection is already in Established state. - /// When false, Established connection always wins (timing-based resolution). - pub deterministic_collision_resolution: bool, } impl From for BgpPeerConfig { @@ -912,33 +911,37 @@ impl From for BgpPeerConfig { Self { host: cfg.host, name: cfg.name, - hold_time: cfg.hold_time, - idle_hold_time: cfg.idle_hold_time, - delay_open: cfg.delay_open, - connect_retry: cfg.connect_retry, - keepalive: cfg.keepalive, - resolution: cfg.resolution, - passive: cfg.passive, - remote_asn: cfg.remote_asn, - min_ttl: cfg.min_ttl, - md5_auth_key: cfg.md5_auth_key, - multi_exit_discriminator: cfg.multi_exit_discriminator, - communities: cfg.communities, - local_pref: cfg.local_pref, - enforce_first_as: cfg.enforce_first_as, - ipv4_unicast: Some(Ipv4UnicastConfig { - nexthop: None, - import_policy: cfg.allow_import.as_ipv4_policy(), - export_policy: cfg.allow_export.as_ipv4_policy(), - }), - ipv6_unicast: None, - vlan_id: cfg.vlan_id, - connect_retry_jitter: Some(JitterRange { - min: 0.75, - max: 1.0, - }), - idle_hold_jitter: None, - deterministic_collision_resolution: false, + parameters: BgpPeerParameters { + hold_time: cfg.parameters.hold_time, + idle_hold_time: cfg.parameters.idle_hold_time, + delay_open: cfg.parameters.delay_open, + connect_retry: cfg.parameters.connect_retry, + keepalive: cfg.parameters.keepalive, + resolution: cfg.parameters.resolution, + passive: cfg.parameters.passive, + remote_asn: cfg.parameters.remote_asn, + min_ttl: cfg.parameters.min_ttl, + md5_auth_key: cfg.parameters.md5_auth_key, + multi_exit_discriminator: cfg + .parameters + .multi_exit_discriminator, + communities: cfg.parameters.communities, + local_pref: cfg.parameters.local_pref, + enforce_first_as: cfg.parameters.enforce_first_as, + ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, + import_policy: cfg.parameters.allow_import.as_ipv4_policy(), + export_policy: cfg.parameters.allow_export.as_ipv4_policy(), + }), + ipv6_unicast: None, + vlan_id: cfg.parameters.vlan_id, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + idle_hold_jitter: None, + deterministic_collision_resolution: false, + }, } } } @@ -1069,6 +1072,9 @@ pub struct ApplyRequest { pub shaper: Option, /// Lists of peers indexed by peer group. pub peers: HashMap>, + /// Lists of unnumbered peers indexed by peer group. + #[serde(default)] + pub unnumbered_peers: HashMap>, } impl From for ApplyRequest { @@ -1085,6 +1091,7 @@ impl From for ApplyRequest { (k, v.into_iter().map(BgpPeerConfig::from).collect()) }) .collect(), + unnumbered_peers: HashMap::default(), } } } diff --git a/bgp/src/policy.rs b/bgp/src/policy.rs index e2f7a492..c956b182 100644 --- a/bgp/src/policy.rs +++ b/bgp/src/policy.rs @@ -424,7 +424,7 @@ mod test { // check that open messages without the 4-octet AS capability code get dropped let asn = 47; let addr = "198.51.100.1".parse().unwrap(); - let m = OpenMessage::new2(asn, 30, 1701); + let m = OpenMessage::new2(asn, 30, 1701, false); let source = std::fs::read_to_string("../bgp/policy/policy-check0.rhai") .unwrap(); @@ -435,7 +435,7 @@ mod test { assert_eq!(result, CheckerResult::Drop); // check that open messages with the 4-octet AS capability code get accepted - let m = OpenMessage::new4(asn.into(), 30, 1701); + let m = OpenMessage::new4(asn.into(), 30, 1701, false); let result = check_incoming_open(m, &ast, asn.into(), addr, init_logger()) .unwrap(); @@ -475,7 +475,7 @@ mod test { // check that open messages without the 4-octet AS capability code get dropped let asn = 100; let addr = "198.51.100.1".parse().unwrap(); - let mut m = OpenMessage::new2(asn, 30, 1701); + let mut m = OpenMessage::new2(asn, 30, 1701, false); let source = std::fs::read_to_string("../bgp/policy/policy-shape0.rhai") .unwrap(); diff --git a/bgp/src/router.rs b/bgp/src/router.rs index 2bdfa07d..ec6a03e3 100644 --- a/bgp/src/router.rs +++ b/bgp/src/router.rs @@ -14,9 +14,10 @@ use crate::{ }, policy::{load_checker, load_shaper}, session::{ - AdminEvent, FsmEvent, NeighborInfo, SessionEndpoint, SessionInfo, - SessionRunner, + AdminEvent, FsmEvent, NeighborInfo, PeerId, SessionEndpoint, + SessionInfo, SessionRunner, }, + unnumbered::UnnumberedManager, }; use mg_common::{lock, read_lock, write_lock}; use rdb::{Asn, Db, Prefix4, Prefix6}; @@ -24,7 +25,7 @@ use rhai::AST; use slog::Logger; use std::{ collections::{BTreeMap, BTreeSet}, - net::{IpAddr, SocketAddr}, + net::SocketAddr, sync::{ Arc, Mutex, MutexGuard, RwLock, atomic::{AtomicBool, Ordering}, @@ -44,8 +45,8 @@ pub struct Router { /// The static configuration associated with this router. pub config: RouterConfig, - /// A set of BGP session runners indexed by peer IP address. - pub sessions: Mutex>>>, + /// A set of BGP session runners indexed by PeerId (IP or interface). + pub sessions: Mutex>>>, /// Compiled policy programs. pub policy: Policy, @@ -60,9 +61,9 @@ pub struct Router { /// graceful shutdown (RFC 8326) with its peers. graceful_shutdown: AtomicBool, - /// A set of event channels indexed by peer IP address. These channels + /// A set of event channels indexed by PeerId. These channels /// are used for cross-peer session communications. - addr_to_session: Arc>>>, + peer_to_session: Arc>>>, /// A fanout is used to distribute originated prefixes to all peer /// sessions. In the event that redistribution becomes supported this @@ -85,11 +86,11 @@ impl Router { config: RouterConfig, log: Logger, db: Db, - addr_to_session: Arc>>>, + peer_to_session: Arc>>>, ) -> Router { Self { config, - addr_to_session, + peer_to_session, log, shutdown: AtomicBool::new(false), graceful_shutdown: AtomicBool::new(false), @@ -101,15 +102,20 @@ impl Router { } } - pub fn get_session(&self, addr: IpAddr) -> Option>> { - lock!(self.sessions).get(&addr).cloned() + // Get the session runner mapped to the peer id + pub fn get_session( + &self, + peer: impl Into, + ) -> Option>> { + let key: PeerId = peer.into(); + lock!(self.sessions).get(&key).cloned() } /// Spawn an FSM thread for the given session. /// This is used both when initially creating sessions and when restarting /// the router. fn spawn_session_thread(&self, session: Arc>) { - let peer_ip = session.neighbor.host.ip(); + let peer_id = &session.neighbor.peer; slog::info!( self.log, "spawning session for {}", @@ -121,7 +127,7 @@ impl Router { ) ); std::thread::Builder::new() - .name(format!("bgp-fsm-{}", peer_ip)) + .name(format!("bgp-fsm-{}", peer_id)) .spawn(move || { session.fsm_start(); }) @@ -133,8 +139,8 @@ impl Router { /// Also cleans up fanout entries for all stopped sessions. fn stop_all_sessions(&self) { let sessions = lock!(self.sessions); - for (addr, s) in sessions.iter() { - self.remove_fanout(*addr); + for (key, s) in sessions.iter() { + self.remove_fanout(key.clone()); s.shutdown(); } } @@ -144,9 +150,9 @@ impl Router { /// references, allowing BgpConnections to drop and their threads to clean up. fn delete_all_sessions(&self) { let sessions = std::mem::take(&mut *lock!(self.sessions)); - for (addr, s) in sessions { - lock!(self.addr_to_session).remove(&addr); - self.remove_fanout(addr); + for (key, s) in sessions { + lock!(self.peer_to_session).remove(&key); + self.remove_fanout(key.clone()); s.shutdown(); } // When `sessions` drops here, Arc references are released @@ -178,10 +184,14 @@ impl Router { } } - pub fn add_fanout4(&self, peer: IpAddr, event_tx: Sender>) { + pub fn add_fanout4( + &self, + peer: impl Into, + event_tx: Sender>, + ) { let mut fanout = write_lock!(self.fanout4); fanout.add_egress( - peer, + peer.into(), Egress { event_tx: Some(event_tx), log: self.log.clone(), @@ -189,10 +199,14 @@ impl Router { ) } - pub fn add_fanout6(&self, peer: IpAddr, event_tx: Sender>) { + pub fn add_fanout6( + &self, + peer: impl Into, + event_tx: Sender>, + ) { let mut fanout = write_lock!(self.fanout6); fanout.add_egress( - peer, + peer.into(), Egress { event_tx: Some(event_tx), log: self.log.clone(), @@ -201,7 +215,8 @@ impl Router { } /// Remove a peer from any fanouts they're a member of. - pub fn remove_fanout(&self, peer: IpAddr) { + pub fn remove_fanout(&self, peer: impl Into) { + let peer_id = peer.into(); // Note: We intentionally use separate locks for fanout4 and fanout6 to allow // independent operation of IPv4 and IPv6 route distribution. There is a brief // window between releasing the fanout4 lock and acquiring the fanout6 lock @@ -216,11 +231,11 @@ impl Router { // 4. FsmState::Established transitions properly handle route announcements { let mut fanout = write_lock!(self.fanout4); - fanout.remove_egress(peer); + fanout.remove_egress(&peer_id); } { let mut fanout = write_lock!(self.fanout6); - fanout.remove_egress(peer); + fanout.remove_egress(&peer_id); } } @@ -232,14 +247,49 @@ impl Router { event_rx: Receiver>, info: SessionInfo, ) -> Result, Error> { - let a2s = lock!(self.addr_to_session); - if a2s.contains_key(&peer.host.ip()) { + let p2s = lock!(self.peer_to_session); + // Use PeerId::Ip for numbered sessions + let key = PeerId::Ip(peer.host.ip()); + if p2s.contains_key(&key) { + Ok(EnsureSessionResult::Updated( + self.update_session(peer, info)?, + )) + } else { + Ok(EnsureSessionResult::New(self.new_session_locked( + p2s, key, peer, bind_addr, event_tx, event_rx, info, None, + )?)) + } + } + + #[allow(clippy::too_many_arguments)] + pub fn ensure_unnumbered_session( + self: &Arc, + interface: String, + peer: PeerConfig, + bind_addr: Option, + event_tx: Sender>, + event_rx: Receiver>, + info: SessionInfo, + unnumbered_manager: Arc, + ) -> Result, Error> { + let p2s = lock!(self.peer_to_session); + let key = PeerId::Interface(interface); + if p2s.contains_key(&key) { + // Session exists, just update config Ok(EnsureSessionResult::Updated( self.update_session(peer, info)?, )) } else { + // Create new unnumbered session Ok(EnsureSessionResult::New(self.new_session_locked( - a2s, peer, bind_addr, event_tx, event_rx, info, + p2s, + key, + peer, + bind_addr, + event_tx, + event_rx, + info, + Some(unnumbered_manager), )?)) } } @@ -252,24 +302,30 @@ impl Router { event_rx: Receiver>, info: SessionInfo, ) -> Result>, Error> { - let a2s = lock!(self.addr_to_session); - if a2s.contains_key(&peer.host.ip()) { + let p2s = lock!(self.peer_to_session); + // Use PeerId::Ip for numbered sessions + let key = PeerId::Ip(peer.host.ip()); + if p2s.contains_key(&key) { Err(Error::PeerExists) } else { self.new_session_locked( - a2s, peer, bind_addr, event_tx, event_rx, info, + p2s, key, peer, bind_addr, event_tx, event_rx, info, + None, // No unnumbered_manager for numbered sessions ) } } + #[allow(clippy::too_many_arguments)] pub fn new_session_locked( self: &Arc, - mut a2s: MutexGuard>>, + mut p2s: MutexGuard>>, + peer_id: PeerId, peer: PeerConfig, bind_addr: Option, event_tx: Sender>, event_rx: Receiver>, info: SessionInfo, + unnumbered_manager: Option>, ) -> Result>, Error> { // Update the SessionInfo with timer values from peer config let mut session_info = info.clone(); @@ -284,19 +340,20 @@ impl Router { let session = Arc::new(Mutex::new(session_info)); - a2s.insert( - peer.host.ip(), + p2s.insert( + peer_id.clone(), SessionEndpoint { event_tx: event_tx.clone(), config: session.clone(), }, ); - drop(a2s); + drop(p2s); let neighbor = NeighborInfo { name: Arc::new(Mutex::new(peer.name.clone())), peer_group: peer.group.clone(), - host: peer.host, + peer: peer_id.clone(), + port: peer.host.port(), }; let runner = Arc::new(SessionRunner::new( @@ -305,10 +362,11 @@ impl Router { event_tx.clone(), neighbor.clone(), self.clone(), + unnumbered_manager, )); self.spawn_session_thread(runner.clone()); - lock!(self.sessions).insert(neighbor.host.ip(), runner.clone()); + lock!(self.sessions).insert(peer_id, runner.clone()); Ok(runner) } @@ -318,7 +376,9 @@ impl Router { peer: PeerConfig, info: SessionInfo, ) -> Result>, Error> { - let session = match lock!(self.sessions).get(&peer.host.ip()) { + // Use PeerId::Ip for numbered sessions + let key = PeerId::Ip(peer.host.ip()); + let session = match lock!(self.sessions).get(&key) { None => return Err(Error::UnknownPeer(peer.host.ip())), Some(s) => s.clone(), }; @@ -328,10 +388,11 @@ impl Router { Ok(session) } - pub fn delete_session(&self, addr: IpAddr) { - lock!(self.addr_to_session).remove(&addr); - self.remove_fanout(addr); - if let Some(s) = lock!(self.sessions).remove(&addr) { + pub fn delete_session(&self, peer: impl Into) { + let peer_id = peer.into(); + lock!(self.peer_to_session).remove(&peer_id); + self.remove_fanout(peer_id.clone()); + if let Some(s) = lock!(self.sessions).remove(&peer_id) { s.shutdown(); } } diff --git a/bgp/src/session.rs b/bgp/src/session.rs index 809213e2..eaf81060 100644 --- a/bgp/src/session.rs +++ b/bgp/src/session.rs @@ -19,20 +19,21 @@ use crate::{ Safi, UpdateMessage, }, params::{ - BgpCapability, DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, - JitterRange, PeerCounters, PeerInfo, PeerTimers, StaticTimerInfo, - TimerConfig, + BgpCapability, BgpPeerParameters, BgpPeerParametersV1, + DynamicTimerInfo, Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange, + PeerCounters, PeerInfo, PeerTimers, StaticTimerInfo, TimerConfig, }, policy::{CheckerResult, ShaperResult}, recv_event_loop, recv_event_return, router::Router, + unnumbered::UnnumberedManager, }; use mg_common::{lock, read_lock, write_lock}; use rdb::{ AddressFamily, Asn, BgpPathProperties, Db, ImportExportPolicy, ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, Prefix6, }; -pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY}; +pub use rdb::{DEFAULT_RIB_PRIORITY_BGP, DEFAULT_ROUTE_PRIORITY, PeerId}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use slog::Logger; @@ -219,6 +220,7 @@ fn select_nexthop( nlri_afi: Afi, local_ip: IpAddr, configured_nexthop: Option, + caps: &BTreeSet, ) -> Result { // Canonicalize the local_ip to handle IPv4-mapped IPv6 addresses let local_ip = local_ip.to_canonical(); @@ -228,16 +230,8 @@ fn select_nexthop( return match (nlri_afi, nexthop) { (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), - // XXX: Extended Next-Hop - (Afi::Ipv4, IpAddr::V6(_)) => Err(Error::InvalidAddress( - "IPv4 routes require IPv4 next-hop (configured mismatch)" - .into(), - )), - // XXX: Extended Next-Hop - (Afi::Ipv6, IpAddr::V4(_)) => Err(Error::InvalidAddress( - "IPv6 routes require IPv6 next-hop (configured mismatch)" - .into(), - )), + (Afi::Ipv4, IpAddr::V6(ipv6)) => v4_over_v6_nexthop(caps, ipv6), + (Afi::Ipv6, IpAddr::V4(ipv4)) => v6_over_v4_nexthop(caps, ipv4), }; } @@ -245,16 +239,57 @@ fn select_nexthop( match (nlri_afi, local_ip) { (Afi::Ipv4, IpAddr::V4(ipv4)) => Ok(BgpNexthop::Ipv4(ipv4)), (Afi::Ipv6, IpAddr::V6(ipv6)) => Ok(BgpNexthop::Ipv6Single(ipv6)), - (Afi::Ipv4, IpAddr::V6(_)) => { - Err(Error::InvalidAddress( - "IPv4 routes require IPv4 next-hop (Extended Next-Hop not negotiated)".into() - )) - } - (Afi::Ipv6, IpAddr::V4(_)) => { - Err(Error::InvalidAddress( - "IPv6 routes require IPv6 next-hop".into() - )) + (Afi::Ipv4, IpAddr::V6(ipv6)) => v4_over_v6_nexthop(caps, ipv6), + (Afi::Ipv6, IpAddr::V4(ipv4)) => v6_over_v4_nexthop(caps, ipv4), + } +} + +/// Derive the nexthop interface binding for unnumbered sessions. +/// +/// For unnumbered sessions (PeerId::Interface) with link-local nexthops, +/// returns the interface name. This binds the nexthop to the specific +/// interface for mg-lower resolution. +/// +/// For numbered sessions or non-link-local nexthops, returns None. +fn derive_nexthop_interface( + peer_id: &PeerId, + nexthop: IpAddr, +) -> Option { + match (peer_id, nexthop) { + (PeerId::Interface(iface), IpAddr::V6(nh6)) + if nh6.is_unicast_link_local() => + { + Some(iface.clone()) } + _ => None, + } +} + +pub fn v4_over_v6_nexthop( + caps: &BTreeSet, + nexthop: Ipv6Addr, +) -> Result { + let v4_over_v6 = caps.iter().any(|x| x.extended_nh_v4_over_v6()); + if v4_over_v6 { + Ok(BgpNexthop::Ipv6Single(nexthop)) + } else { + Err(Error::InvalidAddress(format!( + "Ipv6 nexthop {nexthop} without extended NH v4 over v6 negotiated" + ))) + } +} + +pub fn v6_over_v4_nexthop( + caps: &BTreeSet, + nexthop: Ipv4Addr, +) -> Result { + let v6_over_v4 = caps.iter().any(|x| x.extended_nh_v6_over_v4()); + if v6_over_v4 { + Ok(BgpNexthop::Ipv4(nexthop)) + } else { + Err(Error::InvalidAddress(format!( + "Ipv4 nexthop {nexthop} without extended NH v6 over v4 negotiated" + ))) } } @@ -861,7 +896,7 @@ pub struct SessionInfo { impl SessionInfo { /// Create a SessionInfo from a PeerConfig with minimal defaults for policy fields. /// This is used when only timer configuration is available (e.g., in tests). - pub fn from_peer_config(peer_config: &crate::config::PeerConfig) -> Self { + pub fn from_peer_config(peer_config: &PeerConfig) -> Self { Self { passive_tcp_establishment: false, remote_asn: None, @@ -896,16 +931,83 @@ impl SessionInfo { } } +impl From<&BgpPeerParameters> for SessionInfo { + fn from(value: &BgpPeerParameters) -> Self { + SessionInfo { + passive_tcp_establishment: value.passive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key.clone(), + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities.clone().into_iter().collect(), + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + vlan_id: value.vlan_id, + remote_id: None, + bind_addr: None, + connect_retry_time: Duration::from_secs(value.connect_retry), + keepalive_time: Duration::from_secs(value.keepalive), + hold_time: Duration::from_secs(value.hold_time), + idle_hold_time: Duration::from_secs(value.idle_hold_time), + delay_open_time: Duration::from_secs(value.delay_open), + resolution: Duration::from_millis(value.resolution), + idle_hold_jitter: value.idle_hold_jitter, + connect_retry_jitter: value.connect_retry_jitter, + deterministic_collision_resolution: value + .deterministic_collision_resolution, + ipv4_unicast: value.ipv4_unicast.clone(), + ipv6_unicast: value.ipv6_unicast.clone(), + } + } +} + +impl From<&BgpPeerParametersV1> for SessionInfo { + fn from(value: &BgpPeerParametersV1) -> Self { + SessionInfo { + passive_tcp_establishment: value.passive, + remote_asn: value.remote_asn, + min_ttl: value.min_ttl, + md5_auth_key: value.md5_auth_key.clone(), + multi_exit_discriminator: value.multi_exit_discriminator, + communities: value.communities.clone().into_iter().collect(), + local_pref: value.local_pref, + enforce_first_as: value.enforce_first_as, + vlan_id: value.vlan_id, + remote_id: None, + bind_addr: None, + connect_retry_time: Duration::from_secs(value.connect_retry), + keepalive_time: Duration::from_secs(value.keepalive), + hold_time: Duration::from_secs(value.hold_time), + idle_hold_time: Duration::from_secs(value.idle_hold_time), + delay_open_time: Duration::from_secs(value.delay_open), + resolution: Duration::from_millis(value.resolution), + idle_hold_jitter: None, + connect_retry_jitter: Some(JitterRange { + min: 0.75, + max: 1.0, + }), + deterministic_collision_resolution: false, + ipv4_unicast: Some(Ipv4UnicastConfig { + nexthop: None, + import_policy: value.allow_import.as_ipv4_policy().clone(), + export_policy: value.allow_export.as_ipv4_policy().clone(), + }), + ipv6_unicast: None, + } + } +} + /// Information about a neighbor (peer). #[derive(Debug, Clone)] pub struct NeighborInfo { pub name: Arc>, pub peer_group: String, - pub host: SocketAddr, + pub peer: PeerId, + pub port: u16, } /// Session endpoint that combines the event sender with session configuration. -/// This is used in addr_to_session map to provide both communication channel +/// This is used in peer_to_session map to provide both communication channel /// and policy information for each peer. pub struct SessionEndpoint { /// Event sender for FSM events to this session @@ -1473,6 +1575,9 @@ pub struct SessionRunner { /// Information about the neighbor this session is to peer with. pub neighbor: NeighborInfo, + /// Unnumbered neighbor manager for actively querying NDP state. + pub unnumbered_manager: Option>, + /// A log of the last `MAX_MESSAGE_HISTORY` messages. Keepalives are not /// included in message history. pub message_history: Arc>, @@ -1531,12 +1636,12 @@ pub enum CollisionResolution { impl Drop for SessionRunner { fn drop(&mut self) { - let peer_ip = self.neighbor.host.ip(); + let peer = self.peer_id(); let final_state = *lock!(self.state); session_log_lite!( self, debug, - "dropping session runner for peer {peer_ip} (final state: {final_state})" + "dropping session runner for peer {peer} (final state: {final_state})" ); } } @@ -1550,6 +1655,7 @@ impl SessionRunner { event_tx: Sender>, neighbor: NeighborInfo, router: Arc>, + unnumbered_manager: Option>, ) -> SessionRunner { let session_info = lock!(session); let runner = SessionRunner { @@ -1559,6 +1665,7 @@ impl SessionRunner { asn: router.config.asn, id: router.config.id, neighbor, + unnumbered_manager, state: Arc::new(Mutex::new(FsmStateKind::Idle)), last_state_change: Mutex::new(Instant::now()), clock: Arc::new(SessionClock::new( @@ -1590,6 +1697,39 @@ impl SessionRunner { runner } + /// Get the current peer address for this session. + pub fn get_peer_addr(&self) -> Option { + match &self.neighbor.peer { + PeerId::Interface(iface) => { + // Unnumbered: query UnnumberedManager + if let Some(ref mgr) = self.unnumbered_manager { + mgr.get_neighbor_for_interface(iface).ok().flatten() + } else { + None + } + } + PeerId::Ip(ip) => { + // Numbered: construct from NeighborInfo + Some(SocketAddr::new(*ip, self.neighbor.port)) + } + } + } + + /// Check if this is an unnumbered session. + pub fn is_unnumbered(&self) -> bool { + matches!(self.neighbor.peer, PeerId::Interface(_)) + } + + /// Check if a neighbor is currently available for connection attempts. + pub fn has_neighbor(&self) -> bool { + self.get_peer_addr().is_some() + } + + /// Get the PeerId for this session. + pub fn peer_id(&self) -> PeerId { + self.neighbor.peer.clone() + } + /// Request a peer session shutdown. Does not shut down the session right /// away. Simply sets a flag that the session is to be shut down which will /// be acted upon in the state machine loop. @@ -1598,7 +1738,7 @@ impl SessionRunner { self, info, "session runner (peer {}) received shutdown request, setting shutdown flag", - self.neighbor.host.ip(); + self.peer_id(); ); self.shutdown.store(true, Ordering::Release); } @@ -1678,8 +1818,20 @@ impl SessionRunner { // release lock before calling connect } + let peer_addr = match self.get_peer_addr() { + Some(addr) => addr, + None => { + session_log_lite!( + self, + debug, + "no peer address available, skipping connection attempt" + ); + return; + } + }; + let handle = match Cnx::Connector::connect( - self.neighbor.host, + peer_addr, timeout, self.log.clone(), self.event_tx.clone(), @@ -1959,7 +2111,7 @@ impl SessionRunner { self, info, "session runner (peer: {}) caught shutdown flag", - self.neighbor.host.ip(); + self.peer_id(); ); self.on_shutdown(); return; @@ -5845,9 +5997,10 @@ impl SessionRunner { }; // Ensure the router has a fanout entry for this peer. + let peer_id = self.peer_id(); if pc.ipv4_unicast.negotiated() { write_lock!(self.fanout4).add_egress( - self.neighbor.host.ip(), + peer_id.clone(), crate::fanout::Egress { event_tx: Some(self.event_tx.clone()), log: self.log.clone(), @@ -5856,7 +6009,7 @@ impl SessionRunner { } if pc.ipv6_unicast.negotiated() { write_lock!(self.fanout6).add_egress( - self.neighbor.host.ip(), + peer_id, crate::fanout::Egress { event_tx: Some(self.event_tx.clone()), log: self.log.clone(), @@ -6248,7 +6401,7 @@ impl SessionRunner { AdminEvent::SendRouteRefresh(af) => { self.db.mark_bgp_peer_stale( - pc.conn.peer().ip(), + self.peer_id(), AddressFamily::from(af), ); self.send_route_refresh(&pc.conn, af); @@ -6875,7 +7028,7 @@ impl SessionRunner { self, info, "session runner (peer {}): shutdown start", - self.neighbor.host.ip() + self.peer_id() ); self.cleanup_connections(); @@ -6913,7 +7066,7 @@ impl SessionRunner { self, info, "session runner (peer {}): shutdown complete", - self.neighbor.host.ip() + self.peer_id() ); } @@ -6942,11 +7095,15 @@ impl SessionRunner { })); } if let Some(checker) = read_lock!(self.router.policy.checker).as_ref() { + let peer_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; match crate::policy::check_incoming_open( om.clone(), checker, remote_asn, - self.neighbor.host.ip(), + peer_ip, self.log.clone(), ) { Ok(result) => match result { @@ -7159,24 +7316,35 @@ impl SessionRunner { let capabilities = lock!(self.caps_tx).clone(); // pull hold_time from config, not the clock let hold_time = lock!(self.session).hold_time; + let extended_nexthop = self.v4_over_v6_nexthop(); let mut msg = match self.asn { - Asn::FourOctet(asn) => { - OpenMessage::new4(asn, hold_time.as_secs() as u16, self.id) - } - Asn::TwoOctet(asn) => { - OpenMessage::new2(asn, hold_time.as_secs() as u16, self.id) - } + Asn::FourOctet(asn) => OpenMessage::new4( + asn, + hold_time.as_secs() as u16, + self.id, + extended_nexthop, + ), + Asn::TwoOctet(asn) => OpenMessage::new2( + asn, + hold_time.as_secs() as u16, + self.id, + extended_nexthop, + ), }; msg.add_capabilities(&capabilities); let mut out = Message::from(msg.clone()); if let Some(shaper) = read_lock!(self.router.policy.shaper).as_ref() { let peer_as = lock!(self.session).remote_asn.unwrap_or(0); + let peer_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; match crate::policy::shape_outgoing_open( msg.clone(), shaper, peer_as, - self.neighbor.host.ip(), + peer_ip, self.log.clone(), ) { Ok(result) => match result { @@ -7219,6 +7387,12 @@ impl SessionRunner { } } + fn v4_over_v6_nexthop(&self) -> bool { + let v4_enabled = lock!(self.session).ipv4_unicast.is_some(); + let v6_peer = self.get_peer_info().remote_ip.is_ipv6(); + v4_enabled && v6_peer + } + fn is_ebgp(&self) -> Option { // Query the registry's primary connection if let Some(ConnectionKind::Full(pc)) = @@ -7266,11 +7440,15 @@ impl SessionRunner { ) -> Result { if let Some(shaper) = read_lock!(self.router.policy.shaper).as_ref() { let peer_as = lock!(self.session).remote_asn.unwrap_or(0); + let peer_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; Ok(crate::policy::shape_outgoing_update( update.clone(), shaper, peer_as, - self.neighbor.host.ip(), + peer_ip, self.log.clone(), )?) } else { @@ -7284,13 +7462,17 @@ impl SessionRunner { previous: Option, ) -> Result { let peer_as = lock!(self.session).remote_asn.unwrap_or(0); + let peer_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; let former = match previous { Some(shaper) => crate::policy::shape_outgoing_update( update.clone(), &shaper, peer_as, - self.neighbor.host.ip(), + peer_ip, self.log.clone(), )?, None => ShaperResult::Emit(update.clone().into()), @@ -7320,7 +7502,12 @@ impl SessionRunner { .and_then(|cfg| cfg.nexthop), }; - select_nexthop(nlri_afi, pc.conn.local().ip(), configured_nexthop) + select_nexthop( + nlri_afi, + pc.conn.local().ip(), + configured_nexthop, + &pc.caps, + ) } /// Add peer-specific path attributes to an UPDATE message. @@ -7431,23 +7618,34 @@ impl SessionRunner { // Each RouteUpdate is either an announcement OR withdrawal, never both. let mut update = match route_update { RouteUpdate::V4(RouteUpdate4::Announce(nlri)) => { - let nh4 = match self.derive_nexthop(Afi::Ipv4, pc)? { - BgpNexthop::Ipv4(addr) => addr, - _ => { - return Err(Error::InvalidAddress( - "IPv4 routes require IPv4 next-hop".into(), - )); + match self.derive_nexthop(Afi::Ipv4, pc)? { + BgpNexthop::Ipv4(nh4) => { + let mut path_attributes = self.router.base_attributes(); + path_attributes + .push(PathAttributeValue::NextHop(nh4).into()); + + UpdateMessage { + withdrawn: vec![], + path_attributes, + nlri, + ..Default::default() + } } - }; - - let mut path_attributes = self.router.base_attributes(); - path_attributes.push(PathAttributeValue::NextHop(nh4).into()); + nh6 @ BgpNexthop::Ipv6Single(_) + | nh6 @ BgpNexthop::Ipv6Double(_) => { + let mut path_attrs = self.router.base_attributes(); + let reach = MpReachNlri::ipv4_unicast(nh6, nlri); + path_attrs.push( + PathAttributeValue::MpReachNlri(reach).into(), + ); - UpdateMessage { - withdrawn: vec![], - path_attributes, - nlri, - ..Default::default() + UpdateMessage { + withdrawn: vec![], + path_attributes: path_attrs, + nlri: vec![], + ..Default::default() + } + } } } RouteUpdate::V4(RouteUpdate4::Withdraw(withdrawn)) => { @@ -7603,15 +7801,16 @@ impl SessionRunner { conn_timer!(pc.conn, keepalive).disable(); session_timer!(self, connect_retry).stop(); + let peer_id = self.peer_id(); if pc.ipv4_unicast.negotiated() { - write_lock!(self.fanout4).remove_egress(self.neighbor.host.ip()); + write_lock!(self.fanout4).remove_egress(&peer_id); } if pc.ipv6_unicast.negotiated() { - write_lock!(self.fanout6).remove_egress(self.neighbor.host.ip()); + write_lock!(self.fanout6).remove_egress(&peer_id); } // remove peer prefixes from db - self.db.remove_bgp_prefixes_from_peer(&pc.conn.peer().ip()); + self.db.remove_bgp_prefixes_from_peer(&self.peer_id()); } /// Exit the established state into Idle. @@ -7832,11 +8031,15 @@ impl SessionRunner { self.apply_static_update_policy(&mut update); if let Some(checker) = read_lock!(self.router.policy.checker).as_ref() { + let peer_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; match crate::policy::check_incoming_update( update.clone(), checker, pc.asn, - self.neighbor.host.ip(), + peer_ip, self.log.clone(), ) { Ok(result) => match result { @@ -7965,8 +8168,10 @@ impl SessionRunner { self, warn, pc.conn, - "MP_REACH_NLRI for unnegotiated AFI/SAFI: {}/{}", - afi, safi; + "MP_REACH_NLRI for unnegotiated AFI/SAFI: {}/{}: {:?}", + afi, + safi, + afi_state; ); self.counters @@ -8168,8 +8373,7 @@ impl SessionRunner { .map(Prefix::V4) .collect(); - self.db - .remove_bgp_prefixes(&withdrawn, &pc.conn.peer().ip()); + self.db.remove_bgp_prefixes(&withdrawn, &self.peer_id()); let nlri: Vec = update .nlri @@ -8190,13 +8394,16 @@ impl SessionRunner { as_path.extend(segments.value.iter()); } } + let nexthop_interface = + derive_nexthop_interface(&self.peer_id(), nexthop); let path = rdb::Path { nexthop, + nexthop_interface, shutdown: update.graceful_shutdown(), rib_priority: DEFAULT_RIB_PRIORITY_BGP, bgp: Some(BgpPathProperties { origin_as: pc.asn, - peer: pc.conn.peer().ip(), + peer: self.peer_id(), id: pc.id, med: update.multi_exit_discriminator(), local_pref: update.local_pref(), @@ -8243,13 +8450,18 @@ impl SessionRunner { as_path.extend(segments.value.iter()); } } + let nexthop_interface = derive_nexthop_interface( + &self.peer_id(), + mp_nexthop, + ); let path4 = rdb::Path { nexthop: mp_nexthop, + nexthop_interface, shutdown: update.graceful_shutdown(), rib_priority: DEFAULT_RIB_PRIORITY_BGP, bgp: Some(BgpPathProperties { origin_as: pc.asn, - peer: pc.conn.peer().ip(), + peer: self.peer_id(), id: pc.id, med: update.multi_exit_discriminator(), local_pref: update.local_pref(), @@ -8316,13 +8528,16 @@ impl SessionRunner { as_path.extend(segments.value.iter()); } } + let nexthop_interface = + derive_nexthop_interface(&self.peer_id(), nexthop6); let path6 = rdb::Path { nexthop: nexthop6, + nexthop_interface, shutdown: update.graceful_shutdown(), rib_priority: DEFAULT_RIB_PRIORITY_BGP, bgp: Some(BgpPathProperties { origin_as: pc.asn, - peer: pc.conn.peer().ip(), + peer: self.peer_id(), id: pc.id, med: update.multi_exit_discriminator(), local_pref: update.local_pref(), @@ -8352,10 +8567,8 @@ impl SessionRunner { .map(Prefix::V4) .collect(); - self.db.remove_bgp_prefixes( - &mp_withdrawn4, - &pc.conn.peer().ip(), - ); + self.db + .remove_bgp_prefixes(&mp_withdrawn4, &self.peer_id()); } MpUnreachNlri::Ipv6Unicast(unreach6) => { let originated6 = match self.db.get_origin6() { @@ -8382,8 +8595,7 @@ impl SessionRunner { .map(Prefix::V6) .collect(); - self.db - .remove_bgp_prefixes(&withdrawn6, &pc.conn.peer().ip()); + self.db.remove_bgp_prefixes(&withdrawn6, &self.peer_id()); } } } @@ -8533,7 +8745,9 @@ impl SessionRunner { *lock!(self.neighbor.name) = cfg.name; let mut reset_needed = false; - if self.neighbor.host != cfg.host { + if self.neighbor.peer != PeerId::Ip(cfg.host.ip()) + || self.neighbor.port != cfg.host.port() + { return Err(Error::PeerAddressUpdate); } @@ -8883,7 +9097,10 @@ impl SessionRunner { } }, None => { - let remote_ip = self.neighbor.host.ip(); + let remote_ip = match self.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + }; // We don't have an active connection, so just display the // configured next-hop if it's set or use the unspec addr if not let local_ip = match remote_ip { @@ -8905,7 +9122,7 @@ impl SessionRunner { local_ip, remote_ip, local_tcp_port: 0u16, - remote_tcp_port: self.neighbor.host.port(), + remote_tcp_port: self.neighbor.port, received_capabilities: vec![], timers, counters, @@ -9184,7 +9401,12 @@ mod tests { let configured_nh = ip!("10.0.0.1"); let local_ip = ip!("10.0.0.2"); - let result = select_nexthop(Afi::Ipv4, local_ip, Some(configured_nh)); + let result = select_nexthop( + Afi::Ipv4, + local_ip, + Some(configured_nh), + &BTreeSet::default(), + ); assert!(result.is_ok()); match result.unwrap() { BgpNexthop::Ipv4(addr) => { @@ -9201,7 +9423,12 @@ mod tests { let configured_nh = ip!("2001:db8::1"); let local_ip = ip!("2001:db8::2"); - let result = select_nexthop(Afi::Ipv6, local_ip, Some(configured_nh)); + let result = select_nexthop( + Afi::Ipv6, + local_ip, + Some(configured_nh), + &BTreeSet::default(), + ); assert!(result.is_ok()); match result.unwrap() { BgpNexthop::Ipv6Single(addr) => { @@ -9217,7 +9444,8 @@ mod tests { // No nexthop configured, pure IPv4 local_ip should be used for IPv4 routes let local_ip = ip!("10.0.0.1"); - let result = select_nexthop(Afi::Ipv4, local_ip, None); + let result = + select_nexthop(Afi::Ipv4, local_ip, None, &BTreeSet::default()); assert!(result.is_ok()); match result.unwrap() { BgpNexthop::Ipv4(addr) => { @@ -9235,7 +9463,8 @@ mod tests { // [::]:179 with v6_only=false. let mapped = ip!("::ffff:10.0.0.1"); - let result = select_nexthop(Afi::Ipv4, mapped, None); + let result = + select_nexthop(Afi::Ipv4, mapped, None, &BTreeSet::default()); assert!(result.is_ok()); match result.unwrap() { BgpNexthop::Ipv4(addr) => { @@ -9251,7 +9480,8 @@ mod tests { // No nexthop configured, pure IPv6 local_ip should be used for IPv6 routes let local_ip = ip!("2001:db8::1"); - let result = select_nexthop(Afi::Ipv6, local_ip, None); + let result = + select_nexthop(Afi::Ipv6, local_ip, None, &BTreeSet::default()); assert!(result.is_ok()); match result.unwrap() { BgpNexthop::Ipv6Single(addr) => { @@ -9268,7 +9498,12 @@ mod tests { let nexthop = ip!("2001:db8::1"); let local_ip = ip!("10.0.0.1"); - let result = select_nexthop(Afi::Ipv4, local_ip, Some(nexthop)); + let result = select_nexthop( + Afi::Ipv4, + local_ip, + Some(nexthop), + &BTreeSet::default(), + ); // Should error because IPv4 route needs IPv4 nexthop assert!(result.is_err()); } @@ -9279,7 +9514,12 @@ mod tests { let nexthop = ip!("10.0.0.1"); let local_ip = ip!("2001:db8::1"); - let result = select_nexthop(Afi::Ipv6, local_ip, Some(nexthop)); + let result = select_nexthop( + Afi::Ipv6, + local_ip, + Some(nexthop), + &BTreeSet::default(), + ); // Should error because IPv6 route needs IPv6 nexthop assert!(result.is_err()); } @@ -9289,7 +9529,8 @@ mod tests { // IPv4 route with pure IPv6 local_ip and no configured nexthop = error let local_ip = ip!("2001:db8::1"); - let result = select_nexthop(Afi::Ipv4, local_ip, None); + let result = + select_nexthop(Afi::Ipv4, local_ip, None, &BTreeSet::default()); // Should error because cannot derive IPv4 nexthop from IPv6 connection assert!(result.is_err()); } @@ -9299,7 +9540,8 @@ mod tests { // IPv6 route with pure IPv4 local_ip and no configured nexthop = error let local_ip = ip!("10.0.0.1"); - let result = select_nexthop(Afi::Ipv6, local_ip, None); + let result = + select_nexthop(Afi::Ipv6, local_ip, None, &BTreeSet::default()); // Should error because cannot derive IPv6 nexthop from IPv4 connection assert!(result.is_err()); } diff --git a/bgp/src/test.rs b/bgp/src/test.rs index ed565a7a..594e310b 100644 --- a/bgp/src/test.rs +++ b/bgp/src/test.rs @@ -9,11 +9,12 @@ use crate::{ connection_tcp::{BgpConnectionTcp, BgpListenerTcp}, dispatcher::Dispatcher, params::{Ipv4UnicastConfig, Ipv6UnicastConfig, JitterRange}, - router::Router, + router::{EnsureSessionResult, Router}, session::{ - AdminEvent, ConnectionKind, FsmEvent, FsmStateKind, SessionEndpoint, - SessionInfo, + AdminEvent, ConnectionKind, FsmEvent, FsmStateKind, PeerId, + SessionEndpoint, SessionInfo, SessionRunner, }, + unnumbered_mock::UnnumberedManagerMock, }; use lazy_static::lazy_static; use mg_common::log::init_file_logger; @@ -22,9 +23,13 @@ use mg_common::*; use rdb::{Asn, ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4}; use std::{ collections::{BTreeMap, BTreeSet}, - net::{IpAddr, SocketAddr}, - sync::{Arc, Mutex, mpsc::channel}, - time::Duration, + net::{IpAddr, SocketAddr, SocketAddrV6}, + sync::{ + Arc, Mutex, + atomic::{AtomicU32, Ordering}, + mpsc::channel, + }, + time::{Duration, Instant}, }; // Use non-standard port outside the privileged range to avoid needing privs @@ -294,13 +299,15 @@ where let db = rdb::Db::new(&db_path, log.clone()).expect("create db"); // Create dispatcher - let addr_to_session: Arc< - Mutex>>, + // Phase 4: Use PeerId instead of IpAddr + let peer_to_session: Arc< + Mutex>>, > = Arc::new(Mutex::new(BTreeMap::new())); let dispatcher = Arc::new(Dispatcher::new( - addr_to_session.clone(), + peer_to_session.clone(), logical_router.listen_addr.to_string(), log.clone(), + None, // No unnumbered manager for tests )); // Create router @@ -311,7 +318,7 @@ where }, log.clone(), db.clone(), - addr_to_session.clone(), + peer_to_session.clone(), )); // Start router and dispatcher @@ -1059,7 +1066,7 @@ fn three_router_chain_helper< // making use of the same TCP/IP stack, each listener must bind() to a unique // (ip:port) to avoid collisions (i.e. EADDRINUSE). While the TCP stack can // support connections to multiple peers with the same address and unique -// ports, the `addr_to_session` data structure is keyed by IP address not +// ports, the `peer_to_session` data structure is keyed by IP address not // sockaddr and thus cannot distinguish between two ports on the same IP, e.g. // 127.0.0.1:10179 and 127.0.0.1:20179. It is therefore necessary to have // unique addresses on the system in order to facilitate the use of multiple @@ -1726,3 +1733,1918 @@ fn test_ipv6_routes_ipv4_peer_success() { sockaddr!(&format!("10.0.4.2:{TEST_BGP_PORT}")), ) } + +/// Helper to set up two routers with unnumbered BGP sessions. +/// +/// This creates a realistic unnumbered BGP setup where: +/// - Router 1 and Router 2 each have one or more interfaces +/// - Each interface has a corresponding unnumbered BGP session +/// - NDP mock managers simulate neighbor discovery +/// - Sessions can actually establish and reach Established state +/// +/// Returns: (Router1, Mock1, Sessions1, Router2, Mock2, Sessions2) +#[allow(clippy::type_complexity)] +fn unnumbered_peering_helper( + test_name: &str, + interfaces: Vec<(String, u32)>, // (interface_name, scope_id) + route_exchange: RouteExchange, +) -> ( + Arc>, + Arc, + Vec>>, + Arc>, + Arc, + Vec>>, +) { + let log = init_file_logger(&format!("{}.log", test_name)); + + // Create databases + let db1 = rdb::test::get_test_db(&format!("{}_r1", test_name), log.clone()) + .expect("create db1"); + let db2 = rdb::test::get_test_db(&format!("{}_r2", test_name), log.clone()) + .expect("create db2"); + + // Create mock NDP managers + let mock_ndp1 = UnnumberedManagerMock::new(); + let mock_ndp2 = UnnumberedManagerMock::new(); + + // Register all interfaces in both mocks + for (iface, scope_id) in &interfaces { + mock_ndp1.register_interface(iface.clone(), *scope_id); + mock_ndp2.register_interface(iface.clone(), *scope_id); + } + + // Create session maps + let p2s1: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + let p2s2: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + + // Create one Dispatcher per interface for each router. + // Each Dispatcher binds to a unique link-local address with its interface's scope_id, + // and runs its own accept loop thread. All Dispatchers share the same peer_to_session map. + let mut dispatchers1 = Vec::new(); + let mut dispatchers2 = Vec::new(); + + for (iface, scope_id) in &interfaces { + // Router 1 dispatcher for this interface + let r1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + *scope_id, + )); + let disp1 = Arc::new(Dispatcher::new( + p2s1.clone(), + r1_addr.to_string(), + log.clone(), + Some(mock_ndp1.clone()), + )); + + let d1 = disp1.clone(); + let iface_name = iface.clone(); + std::thread::Builder::new() + .name(format!("bgp-listener-r1-{}", iface_name)) + .spawn(move || { + d1.run::(); + }) + .expect("spawn dispatcher1"); + + dispatchers1.push(disp1); + + // Router 2 dispatcher for this interface + let r2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + *scope_id, + )); + let disp2 = Arc::new(Dispatcher::new( + p2s2.clone(), + r2_addr.to_string(), + log.clone(), + Some(mock_ndp2.clone()), + )); + + let d2 = disp2.clone(); + let iface_name = iface.clone(); + std::thread::Builder::new() + .name(format!("bgp-listener-r2-{}", iface_name)) + .spawn(move || { + d2.run::(); + }) + .expect("spawn dispatcher2"); + + dispatchers2.push(disp2); + } + + // Create routers + let router1 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(64512), + id: 1, + }, + log.clone(), + db1.db().clone(), + p2s1.clone(), + )); + let router2 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(64513), + id: 2, + }, + log.clone(), + db2.db().clone(), + p2s2.clone(), + )); + + router1.run(); + router2.run(); + + // Create sessions for each interface + let mut sessions1 = Vec::new(); + let mut sessions2 = Vec::new(); + + for (iface, scope_id) in &interfaces { + // Router 1 session + let (event_tx1, event_rx1) = channel(); + let bind_addr1 = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + *scope_id, + )); + let session_info1 = create_test_session_info( + route_exchange, + bind_addr1, + SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + *scope_id, + )), + false, + ); + + let peer_config1 = PeerConfig { + name: format!("peer_{}", iface), + group: String::new(), + host: sockaddr!(&format!("[fe80::2]:{TEST_BGP_PORT}")), + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let result1 = router1 + .ensure_unnumbered_session( + iface.clone(), + peer_config1, + Some(bind_addr1), + event_tx1.clone(), + event_rx1, + session_info1, + mock_ndp1.clone(), + ) + .expect("create session1"); + + let session1 = match result1 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + sessions1.push(session1); + + // Router 2 session + let (event_tx2, event_rx2) = channel(); + let bind_addr2 = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + *scope_id, + )); + let session_info2 = create_test_session_info( + route_exchange, + bind_addr2, + SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + *scope_id, + )), + false, + ); + + let peer_config2 = PeerConfig { + name: format!("peer_{}", iface), + group: String::new(), + host: sockaddr!(&format!("[fe80::1]:{TEST_BGP_PORT}")), + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let result2 = router2 + .ensure_unnumbered_session( + iface.clone(), + peer_config2, + Some(bind_addr2), + event_tx2.clone(), + event_rx2, + session_info2, + mock_ndp2.clone(), + ) + .expect("create session2"); + + let session2 = match result2 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + sessions2.push(session2); + + // Discover peers via NDP BEFORE starting sessions + let peer1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + *scope_id, + )); + let peer2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + *scope_id, + )); + + mock_ndp1.discover_peer(iface, peer2_addr).unwrap(); + mock_ndp2.discover_peer(iface, peer1_addr).unwrap(); + + // NOW start the sessions after NDP discovery + event_tx1 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start session1"); + + event_tx2 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start session2"); + } + + (router1, mock_ndp1, sessions1, router2, mock_ndp2, sessions2) +} + +/// Test: Session survives NDP neighbor changes and reconnects via new neighbor after reset. +/// +/// Expected behavior: +/// - Two routers establish BGP session via unnumbered interface +/// - Session reaches Established +/// - Router 1's NDP updates to point to a different peer address +/// - Session STAYS Established (NDP change doesn't affect FSM) +/// - After AdminEvent::Reset, session reconnects using new NDP neighbor +#[test] +fn test_unnumbered_session_survives_peer_change() { + let (router1, mock_ndp1, sessions1, router2, _mock_ndp2, sessions2) = + unnumbered_peering_helper( + "unnumbered_peer_change", + vec![("eth0".to_string(), 2)], + RouteExchange::Ipv4 { nexthop: None }, + ); + + let session1 = &sessions1[0]; + let session2 = &sessions2[0]; + + // Debug: check what's happening + std::thread::sleep(Duration::from_secs(5)); + eprintln!("Session1 state: {:?}", session1.state()); + eprintln!("Session2 state: {:?}", session2.state()); + eprintln!("Session1 peer addr: {:?}", session1.get_peer_addr()); + eprintln!("Session2 peer addr: {:?}", session2.get_peer_addr()); + eprintln!("Session1 is_unnumbered: {}", session1.is_unnumbered()); + eprintln!("Session2 is_unnumbered: {}", session2.is_unnumbered()); + + // Wait for both sessions to reach Established + wait_for_eq!(session1.state(), FsmStateKind::Established); + wait_for_eq!(session2.state(), FsmStateKind::Established); + + // Verify initial peer addresses + let peer2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + 2, + )); + assert_eq!(session1.get_peer_addr(), Some(peer2_addr)); + + // Simulate cable swap: Router 1's interface now sees a different peer + let new_peer_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::99".parse().unwrap(), + 179, + 0, + 2, + )); + mock_ndp1.discover_peer("eth0", new_peer_addr).unwrap(); + + // Router 1's query now returns the new peer + wait_for!(session1.get_peer_addr() == Some(new_peer_addr)); + + // CRITICAL: Session must stay Established (NDP change doesn't affect FSM) + assert_eq!( + session1.state(), + FsmStateKind::Established, + "Session must stay Established when NDP neighbor changes" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "Remote session should also stay Established" + ); + + // Manually reset session1 + session1 + .event_tx + .send(FsmEvent::Admin(AdminEvent::Reset)) + .expect("send reset"); + + // Session should re-establish + wait_for_eq!(session1.state(), FsmStateKind::Established); + + // After reset, session1 still queries the new peer address + assert_eq!(session1.get_peer_addr(), Some(new_peer_addr)); + + // Clean up + router1.shutdown(); + router2.shutdown(); +} + +/// Test: Session handles peer expiry and rediscovery. +/// +/// Expected behavior: +/// - Session established between two routers +/// - Router 1's NDP peer expires (no neighbor) +/// - Session STAYS Established (NDP expiry doesn't trigger FSM transitions) +/// - Peer rediscovered +/// - Session remains Established throughout +#[test] +fn test_unnumbered_peer_expiry_and_rediscovery() { + let (router1, mock_ndp1, sessions1, router2, _mock_ndp2, sessions2) = + unnumbered_peering_helper( + "unnumbered_expiry", + vec![("eth0".to_string(), 2)], + RouteExchange::Ipv4 { nexthop: None }, + ); + + let session1 = &sessions1[0]; + let session2 = &sessions2[0]; + + // Wait for both sessions to reach Established + wait_for_eq!(session1.state(), FsmStateKind::Established); + wait_for_eq!(session2.state(), FsmStateKind::Established); + + // Expire Router 1's NDP neighbor + mock_ndp1.expire_peer("eth0").unwrap(); + + // Router 1's query now returns None + wait_for!(session1.get_peer_addr().is_none()); + + // CRITICAL: Session must STAY Established despite NDP expiry + // Loop for 30s to ensure it doesn't drop to Idle + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(30) { + assert_eq!( + session1.state(), + FsmStateKind::Established, + "Session must stay Established despite NDP peer expiry" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "Remote session should also stay Established" + ); + std::thread::sleep(Duration::from_millis(100)); + } + + // Rediscover peer + let peer2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + 2, + )); + mock_ndp1.discover_peer("eth0", peer2_addr).unwrap(); + + // Router 1's query returns the peer again + wait_for!(session1.get_peer_addr() == Some(peer2_addr)); + + // Sessions should still be Established + assert_eq!(session1.state(), FsmStateKind::Established); + assert_eq!(session2.state(), FsmStateKind::Established); + + // Clean up + router1.shutdown(); + router2.shutdown(); +} + +/// Test: Multiple unnumbered sessions on different interfaces work independently. +/// +/// Expected behavior: +/// - Two routers with two unnumbered sessions each (eth0 and eth1) +/// - All sessions establish independently +/// - NDP changes on eth0 don't affect eth1 +/// - Both sessions stay Established when eth0's NDP changes +#[test] +fn test_multiple_unnumbered_sessions() { + let (router1, mock_ndp1, sessions1, router2, _mock_ndp2, sessions2) = + unnumbered_peering_helper( + "multiple_unnumbered", + vec![("eth0".to_string(), 2), ("eth1".to_string(), 3)], + RouteExchange::Ipv4 { nexthop: None }, + ); + + let session1_eth0 = &sessions1[0]; + let session1_eth1 = &sessions1[1]; + let session2_eth0 = &sessions2[0]; + let session2_eth1 = &sessions2[1]; + + // Wait for all sessions to reach Established + wait_for_eq!(session1_eth0.state(), FsmStateKind::Established); + wait_for_eq!(session1_eth1.state(), FsmStateKind::Established); + wait_for_eq!(session2_eth0.state(), FsmStateKind::Established); + wait_for_eq!(session2_eth1.state(), FsmStateKind::Established); + + // Change Router 1's eth0 NDP neighbor + let new_peer = SocketAddr::V6(SocketAddrV6::new( + "fe80::99".parse().unwrap(), + 179, + 0, + 2, + )); + mock_ndp1.discover_peer("eth0", new_peer).unwrap(); + wait_for!(session1_eth0.get_peer_addr() == Some(new_peer)); + + // CRITICAL: All sessions must stay Established + assert_eq!( + session1_eth0.state(), + FsmStateKind::Established, + "eth0 session must stay Established when NDP changes" + ); + assert_eq!( + session1_eth1.state(), + FsmStateKind::Established, + "eth1 session must stay Established (unaffected by eth0 NDP change)" + ); + assert_eq!( + session2_eth0.state(), + FsmStateKind::Established, + "Remote eth0 session should stay Established" + ); + assert_eq!( + session2_eth1.state(), + FsmStateKind::Established, + "Remote eth1 session should stay Established" + ); + + // Verify eth1 peer address unchanged + let peer2_eth1 = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + 3, + )); + assert_eq!( + session1_eth1.get_peer_addr(), + Some(peer2_eth1), + "eth1 peer should be unchanged" + ); + + // Clean up + router1.shutdown(); + router2.shutdown(); +} + +/// Test: Same link-local address on multiple interfaces. +/// +/// Expected behavior: +/// - Two routers, each with two interfaces (eth0 and eth1) +/// - Both interfaces use fe80::1 as the link-local address +/// - scope_id differentiates them +/// - Both sessions establish independently +/// - NDP changes on one interface don't affect the other +#[test] +fn test_same_linklocal_multiple_interfaces() { + let (router1, mock_ndp1, sessions1, router2, _mock_ndp2, sessions2) = + unnumbered_peering_helper( + "same_linklocal", + vec![("eth0".to_string(), 2), ("eth1".to_string(), 3)], + RouteExchange::Ipv4 { nexthop: None }, + ); + + // Override discovered peers to use same link-local on both interfaces + let linklocal_eth0 = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + 2, // scope_id 2 for eth0 + )); + let linklocal_eth1 = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), // SAME IP + 179, + 0, + 3, // scope_id 3 for eth1 + )); + + // Router 1 sees fe80::1 on both eth0 (scope 2) and eth1 (scope 3) + mock_ndp1.discover_peer("eth0", linklocal_eth0).unwrap(); + mock_ndp1.discover_peer("eth1", linklocal_eth1).unwrap(); + + let session1_eth0 = &sessions1[0]; + let session1_eth1 = &sessions1[1]; + let session2_eth0 = &sessions2[0]; + let session2_eth1 = &sessions2[1]; + + // Wait for all sessions to reach Established + wait_for_eq!(session1_eth0.state(), FsmStateKind::Established); + wait_for_eq!(session1_eth1.state(), FsmStateKind::Established); + wait_for_eq!(session2_eth0.state(), FsmStateKind::Established); + wait_for_eq!(session2_eth1.state(), FsmStateKind::Established); + + // Verify both sessions see the same IP but different scope_id + assert_eq!(session1_eth0.get_peer_addr(), Some(linklocal_eth0)); + assert_eq!(session1_eth1.get_peer_addr(), Some(linklocal_eth1)); + + // Verify they're truly independent: change eth0's peer + let new_peer_eth0 = SocketAddr::V6(SocketAddrV6::new( + "fe80::99".parse().unwrap(), + 179, + 0, + 2, + )); + mock_ndp1.discover_peer("eth0", new_peer_eth0).unwrap(); + wait_for!(session1_eth0.get_peer_addr() == Some(new_peer_eth0)); + + // eth1 should still see fe80::1 with scope 3 + assert_eq!( + session1_eth1.get_peer_addr(), + Some(linklocal_eth1), + "eth1 should still see fe80::1 with its own scope_id" + ); + + // CRITICAL: All sessions must stay Established + assert_eq!(session1_eth0.state(), FsmStateKind::Established); + assert_eq!(session1_eth1.state(), FsmStateKind::Established); + assert_eq!(session2_eth0.state(), FsmStateKind::Established); + assert_eq!(session2_eth1.state(), FsmStateKind::Established); + + // Clean up + router1.shutdown(); + router2.shutdown(); +} + +// ========================================================================= +// Unnumbered BGP Test Infrastructure +// ========================================================================= + +/// Global scope_id counter for allocating unique scope IDs in tests. +static SCOPE_ID_COUNTER: AtomicU32 = AtomicU32::new(100); + +/// Allocate a new unique scope_id for test topologies. +fn next_scope_id() -> u32 { + SCOPE_ID_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// Handle to an unnumbered router instance in a test topology. +struct UnnumberedRouterHandle { + router: Arc>, + dispatchers: Vec>>, + mock_ndp: Arc, + sessions: Vec>>, + _db_guard: rdb::test::TestDb, +} + +impl UnnumberedRouterHandle { + fn shutdown(&self) { + self.router.shutdown(); + for dispatcher in &self.dispatchers { + dispatcher.shutdown(); + } + } +} + +/// Test topology containing multiple unnumbered routers. +struct UnnumberedTopology { + routers: Vec, +} + +impl Drop for UnnumberedTopology { + fn drop(&mut self) { + for router in &self.routers { + router.shutdown(); + } + } +} + +/// Create SessionInfo for unnumbered BGP sessions. +/// +/// Forces nexthops to None so that BGP automatically uses the local link-local address. +fn create_unnumbered_session_info( + route_exchange: RouteExchange, + passive: bool, +) -> SessionInfo { + let (ipv4_unicast, ipv6_unicast) = match route_exchange { + RouteExchange::Ipv4 { .. } => { + let ipv4_cfg = Ipv4UnicastConfig { + nexthop: None, // Let BGP use local link-local address + import_policy: ImportExportPolicy4::default(), + export_policy: ImportExportPolicy4::default(), + }; + (Some(ipv4_cfg), None) + } + RouteExchange::Ipv6 { .. } => { + let ipv6_cfg = Ipv6UnicastConfig { + nexthop: None, // Let BGP use local link-local address + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }; + (None, Some(ipv6_cfg)) + } + RouteExchange::DualStack { .. } => { + let ipv4_cfg = Ipv4UnicastConfig { + nexthop: None, // Let BGP use local link-local address + import_policy: ImportExportPolicy4::default(), + export_policy: ImportExportPolicy4::default(), + }; + let ipv6_cfg = Ipv6UnicastConfig { + nexthop: None, // Let BGP use local link-local address + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + }; + (Some(ipv4_cfg), Some(ipv6_cfg)) + } + }; + + SessionInfo { + passive_tcp_establishment: passive, + remote_asn: None, + remote_id: None, + bind_addr: None, // Unnumbered sessions don't use bind_addr + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: BTreeSet::new(), + local_pref: None, + enforce_first_as: false, + ipv4_unicast, + ipv6_unicast, + vlan_id: None, + // Fixed test timer values + connect_retry_time: Duration::from_secs(1), + keepalive_time: Duration::from_secs(3), + hold_time: Duration::from_secs(6), + idle_hold_time: Duration::from_secs(0), + delay_open_time: Duration::from_secs(0), + resolution: Duration::from_millis(100), + connect_retry_jitter: None, + idle_hold_jitter: None, + deterministic_collision_resolution: false, + } +} + +/// Create a pair of routers with unnumbered BGP sessions. +/// +/// # Arguments +/// * `test_name` - Name of the test (used for log files and database paths) +/// * `interface_name` - Interface name for the unnumbered session +/// * `scope_id` - Scope ID for the link-local addresses +/// * `route_exchange` - Route exchange configuration +/// +/// # Returns +/// Topology with two routers and established unnumbered sessions. +fn unnumbered_pair( + test_name: &str, + interface_name: &str, + scope_id: u32, + route_exchange: RouteExchange, +) -> UnnumberedTopology { + let log = init_file_logger(&format!("{}.log", test_name)); + + // Create databases with unique paths + let db1 = rdb::test::get_test_db(&format!("{}_r1", test_name), log.clone()) + .expect("create db1"); + let db2 = rdb::test::get_test_db(&format!("{}_r2", test_name), log.clone()) + .expect("create db2"); + + // Create mock NDP managers + let mock_ndp1 = UnnumberedManagerMock::new(); + let mock_ndp2 = UnnumberedManagerMock::new(); + + // Register interface in both mocks + mock_ndp1.register_interface(interface_name.to_string(), scope_id); + mock_ndp2.register_interface(interface_name.to_string(), scope_id); + + // Allocate link-local addresses with scope_id + let r1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + scope_id, + )); + let r2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + scope_id, + )); + + // Create session maps + let p2s1: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + let p2s2: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + + // Create dispatchers + let dispatcher1 = Arc::new(Dispatcher::new( + p2s1.clone(), + r1_addr.to_string(), + log.clone(), + Some(mock_ndp1.clone()), + )); + let dispatcher2 = Arc::new(Dispatcher::new( + p2s2.clone(), + r2_addr.to_string(), + log.clone(), + Some(mock_ndp2.clone()), + )); + + // Start dispatchers in background threads + let d1 = dispatcher1.clone(); + std::thread::Builder::new() + .name(format!("bgp-listener-{}-r1", test_name)) + .spawn(move || { + d1.run::(); + }) + .expect("spawn dispatcher1"); + + let d2 = dispatcher2.clone(); + std::thread::Builder::new() + .name(format!("bgp-listener-{}-r2", test_name)) + .spawn(move || { + d2.run::(); + }) + .expect("spawn dispatcher2"); + + // Create routers + let router1 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(64512), + id: 1, + }, + log.clone(), + db1.db().clone(), + p2s1.clone(), + )); + let router2 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(64513), + id: 2, + }, + log.clone(), + db2.db().clone(), + p2s2.clone(), + )); + + router1.run(); + router2.run(); + + // Create sessions + let (event_tx1, event_rx1) = channel(); + let session_info1 = create_unnumbered_session_info(route_exchange, false); + + let peer_config1 = PeerConfig { + name: format!("peer_{}", interface_name), + group: String::new(), + host: r2_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let result1 = router1 + .ensure_unnumbered_session( + interface_name.to_string(), + peer_config1, + Some(r1_addr), + event_tx1.clone(), + event_rx1, + session_info1, + mock_ndp1.clone(), + ) + .expect("create session1"); + + let session1 = match result1 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + let (event_tx2, event_rx2) = channel(); + let session_info2 = create_unnumbered_session_info(route_exchange, false); + + let peer_config2 = PeerConfig { + name: format!("peer_{}", interface_name), + group: String::new(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + + let result2 = router2 + .ensure_unnumbered_session( + interface_name.to_string(), + peer_config2, + Some(r2_addr), + event_tx2.clone(), + event_rx2, + session_info2, + mock_ndp2.clone(), + ) + .expect("create session2"); + + let session2 = match result2 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + // Discover peers via NDP + mock_ndp1 + .discover_peer(interface_name, r2_addr) + .expect("discover peer on r1"); + mock_ndp2 + .discover_peer(interface_name, r1_addr) + .expect("discover peer on r2"); + + // Start sessions + event_tx1 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start session1"); + event_tx2 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start session2"); + + // Build topology + UnnumberedTopology { + routers: vec![ + UnnumberedRouterHandle { + router: router1, + dispatchers: vec![dispatcher1], + mock_ndp: mock_ndp1, + sessions: vec![session1], + _db_guard: db1, + }, + UnnumberedRouterHandle { + router: router2, + dispatchers: vec![dispatcher2], + mock_ndp: mock_ndp2, + sessions: vec![session2], + _db_guard: db2, + }, + ], + } +} + +/// Create a three-router chain topology with unnumbered BGP sessions. +/// +/// Topology: R1 <--r1_r2_interface--> R2 <--r2_r3_interface--> R3 +/// +/// This topology demonstrates: +/// - R2 as a transit router with two unnumbered interfaces +/// - Same link-local IP (fe80::2) on R2 with different scope_ids +/// - Scope isolation: NDP changes on one interface don't affect the other +/// +/// # Link-Local Addresses +/// - R1: fe80::1%r1_r2_scope_id (single interface) +/// - R2: fe80::2%r1_r2_scope_id (r1_r2_interface) + fe80::2%r2_r3_scope_id (r2_r3_interface) +/// - R3: fe80::3%r2_r3_scope_id (single interface) +fn unnumbered_three_router_chain( + test_name: &str, + r1_r2_interface: &str, + r1_r2_scope_id: u32, + r2_r3_interface: &str, + r2_r3_scope_id: u32, + route_exchange: RouteExchange, +) -> UnnumberedTopology { + let log = init_file_logger(&format!("{}.log", test_name)); + + // Create databases with unique paths + let db1 = rdb::test::get_test_db(&format!("{}_r1", test_name), log.clone()) + .expect("create db1"); + let db2 = rdb::test::get_test_db(&format!("{}_r2", test_name), log.clone()) + .expect("create db2"); + let db3 = rdb::test::get_test_db(&format!("{}_r3", test_name), log.clone()) + .expect("create db3"); + + // Create mock NDP managers + let mock_ndp1 = UnnumberedManagerMock::new(); + let mock_ndp2 = UnnumberedManagerMock::new(); + let mock_ndp3 = UnnumberedManagerMock::new(); + + // Register interfaces with scope_ids + mock_ndp1.register_interface(r1_r2_interface.to_string(), r1_r2_scope_id); + mock_ndp2.register_interface(r1_r2_interface.to_string(), r1_r2_scope_id); + mock_ndp2.register_interface(r2_r3_interface.to_string(), r2_r3_scope_id); + mock_ndp3.register_interface(r2_r3_interface.to_string(), r2_r3_scope_id); + + // Link-local addresses for each router + // IMPORTANT: R1 and R3 both use fe80::1 to test peer isolation bug + // In real unnumbered BGP, different routers can use the same link-local + // address because they're on different interfaces (different scope_ids) + let r1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + r1_r2_scope_id, + )); + let r2_eth0_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + r1_r2_scope_id, + )); + let r2_eth1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + r2_r3_scope_id, + )); + let r3_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), // Same as R1, different scope_id + 179, + 0, + r2_r3_scope_id, + )); + + // Create session maps + let p2s1: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + let p2s2: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + let p2s3: Arc< + Mutex>>, + > = Arc::new(Mutex::new(BTreeMap::new())); + + // Create Dispatcher for R1 (single interface) + let disp1 = Arc::new(Dispatcher::new( + p2s1.clone(), + r1_addr.to_string(), + log.clone(), + Some(mock_ndp1.clone()), + )); + std::thread::Builder::new() + .name(format!("bgp-listener-r1-{}", r1_r2_interface)) + .spawn({ + let d = disp1.clone(); + move || d.run::() + }) + .expect("spawn r1 dispatcher"); + + // Create Dispatchers for R2 (two interfaces) + let disp2_eth0 = Arc::new(Dispatcher::new( + p2s2.clone(), + r2_eth0_addr.to_string(), + log.clone(), + Some(mock_ndp2.clone()), + )); + std::thread::Builder::new() + .name(format!("bgp-listener-r2-{}", r1_r2_interface)) + .spawn({ + let d = disp2_eth0.clone(); + move || d.run::() + }) + .expect("spawn r2 eth0 dispatcher"); + + let disp2_eth1 = Arc::new(Dispatcher::new( + p2s2.clone(), + r2_eth1_addr.to_string(), + log.clone(), + Some(mock_ndp2.clone()), + )); + std::thread::Builder::new() + .name(format!("bgp-listener-r2-{}", r2_r3_interface)) + .spawn({ + let d = disp2_eth1.clone(); + move || d.run::() + }) + .expect("spawn r2 eth1 dispatcher"); + + // Create Dispatcher for R3 (single interface) + let disp3 = Arc::new(Dispatcher::new( + p2s3.clone(), + r3_addr.to_string(), + log.clone(), + Some(mock_ndp3.clone()), + )); + std::thread::Builder::new() + .name(format!("bgp-listener-r3-{}", r2_r3_interface)) + .spawn({ + let d = disp3.clone(); + move || d.run::() + }) + .expect("spawn r3 dispatcher"); + + // Create routers + let router1 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(65001), + id: 1, + }, + log.clone(), + db1.db().clone(), + p2s1.clone(), + )); + let router2 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(65002), + id: 2, + }, + log.clone(), + db2.db().clone(), + p2s2.clone(), + )); + let router3 = Arc::new(Router::new( + RouterConfig { + asn: Asn::FourOctet(65003), + id: 3, + }, + log.clone(), + db3.db().clone(), + p2s3.clone(), + )); + + router1.run(); + router2.run(); + router3.run(); + + // Create sessions + + // R1 session to R2 + let (event_tx1, event_rx1) = channel(); + let session_info1 = create_unnumbered_session_info(route_exchange, false); + let peer_config1 = PeerConfig { + name: format!("r1_to_r2_{}", r1_r2_interface), + group: String::new(), + host: r2_eth0_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let result1 = router1 + .ensure_unnumbered_session( + r1_r2_interface.to_string(), + peer_config1, + Some(r1_addr), + event_tx1.clone(), + event_rx1, + session_info1, + mock_ndp1.clone(), + ) + .expect("create r1 session"); + let session1 = match result1 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + // R2 session to R1 (eth0) + let (event_tx2_r1, event_rx2_r1) = channel(); + let session_info2_r1 = + create_unnumbered_session_info(route_exchange, false); + let peer_config2_r1 = PeerConfig { + name: format!("r2_to_r1_{}", r1_r2_interface), + group: String::new(), + host: r1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let result2_r1 = router2 + .ensure_unnumbered_session( + r1_r2_interface.to_string(), + peer_config2_r1, + Some(r2_eth0_addr), + event_tx2_r1.clone(), + event_rx2_r1, + session_info2_r1, + mock_ndp2.clone(), + ) + .expect("create r2-r1 session"); + let session2_r1 = match result2_r1 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + // R2 session to R3 (eth1) + let (event_tx2_r3, event_rx2_r3) = channel(); + let session_info2_r3 = + create_unnumbered_session_info(route_exchange, false); + let peer_config2_r3 = PeerConfig { + name: format!("r2_to_r3_{}", r2_r3_interface), + group: String::new(), + host: r3_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let result2_r3 = router2 + .ensure_unnumbered_session( + r2_r3_interface.to_string(), + peer_config2_r3, + Some(r2_eth1_addr), + event_tx2_r3.clone(), + event_rx2_r3, + session_info2_r3, + mock_ndp2.clone(), + ) + .expect("create r2-r3 session"); + let session2_r3 = match result2_r3 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + // R3 session to R2 + let (event_tx3, event_rx3) = channel(); + let session_info3 = create_unnumbered_session_info(route_exchange, false); + let peer_config3 = PeerConfig { + name: format!("r3_to_r2_{}", r2_r3_interface), + group: String::new(), + host: r2_eth1_addr, + hold_time: 6, + idle_hold_time: 0, + delay_open: 0, + connect_retry: 1, + keepalive: 3, + resolution: 100, + }; + let result3 = router3 + .ensure_unnumbered_session( + r2_r3_interface.to_string(), + peer_config3, + Some(r3_addr), + event_tx3.clone(), + event_rx3, + session_info3, + mock_ndp3.clone(), + ) + .expect("create r3 session"); + let session3 = match result3 { + EnsureSessionResult::New(s) => s, + EnsureSessionResult::Updated(s) => s, + }; + + // Discover peers via NDP + mock_ndp1 + .discover_peer(r1_r2_interface, r2_eth0_addr) + .expect("r1 discovers r2"); + mock_ndp2 + .discover_peer(r1_r2_interface, r1_addr) + .expect("r2 discovers r1 on eth0"); + mock_ndp2 + .discover_peer(r2_r3_interface, r3_addr) + .expect("r2 discovers r3 on eth1"); + mock_ndp3 + .discover_peer(r2_r3_interface, r2_eth1_addr) + .expect("r3 discovers r2"); + + // Start all sessions + event_tx1 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start r1 session"); + event_tx2_r1 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start r2-r1 session"); + event_tx2_r3 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start r2-r3 session"); + event_tx3 + .send(FsmEvent::Admin(AdminEvent::ManualStart)) + .expect("start r3 session"); + + // Build topology + UnnumberedTopology { + routers: vec![ + UnnumberedRouterHandle { + router: router1, + dispatchers: vec![disp1], + mock_ndp: mock_ndp1, + sessions: vec![session1], + _db_guard: db1, + }, + UnnumberedRouterHandle { + router: router2, + dispatchers: vec![disp2_eth0, disp2_eth1], + mock_ndp: mock_ndp2, + sessions: vec![session2_r1, session2_r3], + _db_guard: db2, + }, + UnnumberedRouterHandle { + router: router3, + dispatchers: vec![disp3], + mock_ndp: mock_ndp3, + sessions: vec![session3], + _db_guard: db3, + }, + ], + } +} + +// ========================================================================= +// Unnumbered BGP Test Cases +// ========================================================================= + +/// Test: Session survives NDP changes without FSM state transitions. +/// +/// This test verifies that: +/// 1. Sessions establish normally with initial NDP neighbors +/// 2. Updating NDP neighbor to new IP doesn't affect FSM state +/// 3. get_peer_addr() reflects the new NDP neighbor +/// 4. Sessions stay Established throughout NDP changes +/// 5. Expiring NDP neighbor (get_peer_addr() -> None) doesn't affect FSM +/// 6. Rediscovering original peer works correctly +#[test] +fn test_unnumbered_unaffected_by_ndp() { + let scope_id = next_scope_id(); + let topo = unnumbered_pair( + "ndp_changes", + "eth0", + scope_id, + RouteExchange::Ipv4 { nexthop: None }, + ); + + let r1 = &topo.routers[0]; + let r2 = &topo.routers[1]; + let session1 = &r1.sessions[0]; + let session2 = &r2.sessions[0]; + + // Step 1: Wait for Established state on both sessions + wait_for_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session should reach Established" + ); + wait_for_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session should reach Established" + ); + + // Verify initial peer addresses + let initial_r2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + scope_id, + )); + let initial_r1_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 179, + 0, + scope_id, + )); + assert_eq!( + session1.get_peer_addr(), + Some(initial_r2_addr), + "R1 should see R2's address initially" + ); + assert_eq!( + session2.get_peer_addr(), + Some(initial_r1_addr), + "R2 should see R1's address initially" + ); + + // Step 2: Update NDP neighbor to new IP on R1 + let new_peer_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::99".parse().unwrap(), + 179, + 0, + scope_id, + )); + r1.mock_ndp + .discover_peer("eth0", new_peer_addr) + .expect("update peer on R1"); + + // Step 3: Verify get_peer_addr() returns new IP + wait_for!( + session1.get_peer_addr() == Some(new_peer_addr), + "R1 should see new peer address" + ); + + // Step 4: Assert sessions stay Established + assert_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session must stay Established after NDP update" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session must stay Established after R1's NDP update" + ); + + // Verify connection still active + assert_eq!( + session1.connection_count(), + 1, + "R1 should still have active connection" + ); + assert_eq!( + session2.connection_count(), + 1, + "R2 should still have active connection" + ); + + // Step 5: Expire NDP neighbor on R1 + r1.mock_ndp.expire_peer("eth0").expect("expire peer on R1"); + + // Step 6: Verify get_peer_addr() returns None + wait_for!( + session1.get_peer_addr().is_none(), + "R1 should see no peer after expiry" + ); + + // Step 7: Assert sessions still stay Established + // Give it a few seconds to ensure no FSM transitions occur + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(3) { + assert_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session must stay Established despite NDP expiry" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session must stay Established despite R1's NDP expiry" + ); + std::thread::sleep(Duration::from_millis(100)); + } + + // Step 8: Rediscover original peer + r1.mock_ndp + .discover_peer("eth0", initial_r2_addr) + .expect("rediscover peer on R1"); + + // Step 9: Verify get_peer_addr() returns original peer + wait_for!( + session1.get_peer_addr() == Some(initial_r2_addr), + "R1 should see original peer after rediscovery" + ); + + // Step 10: Assert sessions remain Established throughout + assert_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session should still be Established" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session should still be Established" + ); + + // Topology cleanup happens via Drop +} + +/// Test: Session reconnects with new peer address after AdminEvent::Reset. +/// +/// This test verifies that: +/// 1. Sessions establish normally with initial NDP neighbors +/// 2. Updating NDP neighbor to new IP doesn't affect FSM state (session stays Established) +/// 3. After AdminEvent::Reset, session tears down and re-establishes +/// 4. Reconnection uses the current NDP neighbor (new IP), not the original +#[test] +fn test_unnumbered_ndp_change() { + let scope_id = next_scope_id(); + let topo = unnumbered_pair( + "ndp_change_reset", + "eth0", + scope_id, + RouteExchange::Ipv4 { nexthop: None }, + ); + + let r1 = &topo.routers[0]; + let r2 = &topo.routers[1]; + let session1 = &r1.sessions[0]; + let session2 = &r2.sessions[0]; + + // Step 1: Wait for Established state on both sessions + wait_for_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session should reach Established" + ); + wait_for_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session should reach Established" + ); + + // Verify initial peer addresses + let initial_r2_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::2".parse().unwrap(), + 179, + 0, + scope_id, + )); + assert_eq!( + session1.get_peer_addr(), + Some(initial_r2_addr), + "R1 should see R2's initial address" + ); + + // Step 2: Change NDP neighbor to new IP on R1 + let new_peer_addr = SocketAddr::V6(SocketAddrV6::new( + "fe80::88".parse().unwrap(), + 179, + 0, + scope_id, + )); + r1.mock_ndp + .discover_peer("eth0", new_peer_addr) + .expect("update peer on R1"); + + // Step 3: Verify get_peer_addr() returns new IP + wait_for!( + session1.get_peer_addr() == Some(new_peer_addr), + "R1 should see new peer address" + ); + + // Step 4: Verify session stays Established despite NDP change + assert_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session must stay Established after NDP update" + ); + assert_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session must stay Established after R1's NDP update" + ); + + // Step 5: Send AdminEvent::Reset to R1's session + session1 + .event_tx + .send(FsmEvent::Admin(AdminEvent::Reset)) + .expect("send reset to R1 session"); + + // Step 6 & 7: Wait for sessions to re-establish + // The session will tear down and reconnect using the current NDP neighbor (new IP). + // The FSM transitions through Idle very quickly, so we wait directly for re-establishment. + wait_for_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session should re-establish after reset" + ); + wait_for_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session should re-establish after R1's reset" + ); + + // Step 8: Verify reconnection used the new peer address + assert_eq!( + session1.get_peer_addr(), + Some(new_peer_addr), + "R1 should still see new peer address after reconnection" + ); + + // Verify connections are active + assert_eq!( + session1.connection_count(), + 1, + "R1 should have active connection" + ); + assert_eq!( + session2.connection_count(), + 1, + "R2 should have active connection" + ); + + // Topology cleanup happens via Drop +} + +/// Test: Three-router chain with same link-local IP on different interfaces. +/// +/// This test verifies: +/// 1. R2 can run two unnumbered sessions using the same link-local IP (fe80::2) +/// on different interfaces with different scope_ids +/// 2. All sessions establish correctly +/// 3. NDP changes on one interface (eth0) don't affect the other (eth1) +/// 4. Sessions remain Established despite NDP changes on one interface +/// 5. scope_id properly isolates sessions +#[test] +fn test_three_router_chain_unnumbered() { + let r1_r2_scope_id = next_scope_id(); + let r2_r3_scope_id = next_scope_id(); + + let topo = unnumbered_three_router_chain( + "three_chain", + "eth0", + r1_r2_scope_id, + "eth1", + r2_r3_scope_id, + RouteExchange::Ipv4 { nexthop: None }, + ); + + let r1 = &topo.routers[0]; + let r2 = &topo.routers[1]; + let r3 = &topo.routers[2]; + + // Step 1: Verify session counts + assert_eq!(r1.sessions.len(), 1, "R1 should have 1 session"); + assert_eq!(r2.sessions.len(), 2, "R2 should have 2 sessions"); + assert_eq!(r3.sessions.len(), 1, "R3 should have 1 session"); + + let r1_session = &r1.sessions[0]; + let r2_eth0_session = &r2.sessions[0]; // R2 to R1 + let r2_eth1_session = &r2.sessions[1]; // R2 to R3 + let r3_session = &r3.sessions[0]; + + // Step 2: Wait for all sessions to reach Established + wait_for_eq!( + r1_session.state(), + FsmStateKind::Established, + "R1 session should reach Established" + ); + wait_for_eq!( + r2_eth0_session.state(), + FsmStateKind::Established, + "R2 eth0 session should reach Established" + ); + wait_for_eq!( + r2_eth1_session.state(), + FsmStateKind::Established, + "R2 eth1 session should reach Established" + ); + wait_for_eq!( + r3_session.state(), + FsmStateKind::Established, + "R3 session should reach Established" + ); + + // Step 3: Verify R2's two sessions use different scope_ids + let r2_eth0_peer = r2_eth0_session + .get_peer_addr() + .expect("R2 eth0 should have peer"); + let r2_eth1_peer = r2_eth1_session + .get_peer_addr() + .expect("R2 eth1 should have peer"); + + // Extract scope_ids from peer addresses + let eth0_scope = if let SocketAddr::V6(v6) = r2_eth0_peer { + v6.scope_id() + } else { + panic!("R2 eth0 peer should be IPv6"); + }; + let eth1_scope = if let SocketAddr::V6(v6) = r2_eth1_peer { + v6.scope_id() + } else { + panic!("R2 eth1 peer should be IPv6"); + }; + + assert_eq!( + eth0_scope, r1_r2_scope_id, + "R2 eth0 should use r1_r2_scope_id" + ); + assert_eq!( + eth1_scope, r2_r3_scope_id, + "R2 eth1 should use r2_r3_scope_id" + ); + assert_ne!( + eth0_scope, eth1_scope, + "R2's two sessions should have different scope_ids" + ); + + // Step 4: Change NDP on R2's eth0 interface to a new peer + let new_eth0_peer = SocketAddr::V6(SocketAddrV6::new( + "fe80::99".parse().unwrap(), + 179, + 0, + r1_r2_scope_id, + )); + + r2.mock_ndp + .discover_peer("eth0", new_eth0_peer) + .expect("update eth0 peer on R2"); + + // Step 5: Verify only eth0 session's get_peer_addr() changes + wait_for!( + r2_eth0_session.get_peer_addr() == Some(new_eth0_peer), + "R2 eth0 session should see new peer address" + ); + + // Step 6: Verify eth1 session unaffected + assert_eq!( + r2_eth1_session.get_peer_addr(), + Some(r2_eth1_peer), + "R2 eth1 session should still have original peer" + ); + + // Step 7: Assert both R2 sessions stay Established + assert_eq!( + r2_eth0_session.state(), + FsmStateKind::Established, + "R2 eth0 session should stay Established after NDP change" + ); + assert_eq!( + r2_eth1_session.state(), + FsmStateKind::Established, + "R2 eth1 session should stay Established" + ); + + // Verify all other sessions also stayed Established + assert_eq!( + r1_session.state(), + FsmStateKind::Established, + "R1 session should stay Established" + ); + assert_eq!( + r3_session.state(), + FsmStateKind::Established, + "R3 session should stay Established" + ); + + // Verify all connections are active + assert_eq!( + r1_session.connection_count(), + 1, + "R1 should have active connection" + ); + assert_eq!( + r2_eth0_session.connection_count(), + 1, + "R2 eth0 should have active connection" + ); + assert_eq!( + r2_eth1_session.connection_count(), + 1, + "R2 eth1 should have active connection" + ); + assert_eq!( + r3_session.connection_count(), + 1, + "R3 should have active connection" + ); + + // Step 8: Test route exchange and cleanup with peer isolation + // This tests that route cleanup properly distinguishes between unnumbered + // peers that may share the same link-local IP but have different scope_ids. + + // Step 8a: Originate routes from R1 and R3 + r1.router + .create_origin4(vec![cidr!("10.1.0.0/24")]) + .expect("originate IPv4 route on R1"); + r3.router + .create_origin4(vec![cidr!("10.3.0.0/24")]) + .expect("originate IPv4 route on R3"); + + // Step 8b: Verify R2 receives both routes + let r1_prefix = Prefix::V4(cidr!("10.1.0.0/24")); + let r3_prefix = Prefix::V4(cidr!("10.3.0.0/24")); + + wait_for!( + !r2.router.db.get_prefix_paths(&r1_prefix).is_empty(), + "R2 should receive route from R1" + ); + wait_for!( + !r2.router.db.get_prefix_paths(&r3_prefix).is_empty(), + "R2 should receive route from R3" + ); + + let r1_paths = r2.router.db.get_prefix_paths(&r1_prefix); + let r3_paths = r2.router.db.get_prefix_paths(&r3_prefix); + assert_eq!(r1_paths.len(), 1, "Should have exactly one path from R1"); + assert_eq!(r3_paths.len(), 1, "Should have exactly one path from R3"); + + // Step 8c: Shutdown R1 to bring down the BGP session to R2 + r1.shutdown(); + + // Step 8d: Wait for R1's session to tear down + wait_for_neq!( + r1_session.state(), + FsmStateKind::Established, + "R1 session should tear down after shutdown" + ); + wait_for_neq!( + r2_eth0_session.state(), + FsmStateKind::Established, + "R2 eth0 session should tear down after R1 shutdown" + ); + + // Step 8e: Verify R1's routes are withdrawn + wait_for!( + r2.router.db.get_prefix_paths(&r1_prefix).is_empty(), + "R2 should withdraw R1's routes after session teardown" + ); + + // Step 8f: Verify R3's routes are still present + // This assertion is expected to FAIL if the bug exists: + // BgpPathProperties.peer is an IpAddr (doesn't include scope_id). + // When R1's session tears down, remove_bgp_prefixes_from_peer() is called + // with R1's link-local address (fe80::1). If R3 also uses a link-local + // address without scope_id differentiation, the cleanup might incorrectly + // match and remove R3's routes too. + let r3_paths_after = r2.router.db.get_prefix_paths(&r3_prefix); + assert_eq!( + r3_paths_after.len(), + 1, + "R2 should still have R3's routes after R1 shutdown. \ + BUG: peer tracking uses IpAddr without scope_id, causing \ + incorrect route removal when unnumbered peers share link-local IPs" + ); + + // Verify R2-R3 session is still Established + assert_eq!( + r2_eth1_session.state(), + FsmStateKind::Established, + "R2 eth1 session to R3 should stay Established" + ); + assert_eq!( + r3_session.state(), + FsmStateKind::Established, + "R3 session should stay Established" + ); + + // Topology cleanup happens via Drop +} + +/// Test: Dual-stack route exchange over unnumbered BGP session. +/// +/// This test verifies: +/// 1. IPv4 routes can be originated and received over unnumbered sessions +/// 2. IPv6 routes can be originated and received over unnumbered sessions +/// 3. Nexthops are set to the peer's link-local IPv6 address +/// 4. Routes are properly withdrawn when session goes down +#[test] +fn test_unnumbered_dualstack_route_exchange() { + let scope_id = next_scope_id(); + let topo = unnumbered_pair( + "dualstack_routes", + "eth0", + scope_id, + RouteExchange::DualStack { + ipv4_nexthop: None, + ipv6_nexthop: None, + }, + ); + + let r1 = &topo.routers[0]; + let r2 = &topo.routers[1]; + let session1 = &r1.sessions[0]; + let session2 = &r2.sessions[0]; + + // Wait for Established state on both sessions + wait_for_eq!( + session1.state(), + FsmStateKind::Established, + "R1 session should reach Established" + ); + wait_for_eq!( + session2.state(), + FsmStateKind::Established, + "R2 session should reach Established" + ); + + // Define the expected nexthop (R1's link-local address) + let r1_linklocal: IpAddr = ip!("fe80::1"); + let r2_linklocal: IpAddr = ip!("fe80::2"); + + // Step 1: Originate IPv4 route from R1 + r1.router + .create_origin4(vec![cidr!("10.1.0.0/24")]) + .expect("originate IPv4 route on R1"); + + // Step 2: Verify R2 receives IPv4 route with link-local nexthop + let ipv4_prefix = Prefix::V4(cidr!("10.1.0.0/24")); + wait_for!( + !r2.router.db.get_prefix_paths(&ipv4_prefix).is_empty(), + "R2 should receive IPv4 route from R1" + ); + + let ipv4_paths = r2.router.db.get_prefix_paths(&ipv4_prefix); + assert_eq!(ipv4_paths.len(), 1, "Should have exactly one path for IPv4"); + assert_eq!( + ipv4_paths[0].nexthop, r1_linklocal, + "IPv4 route nexthop should be R1's link-local address" + ); + + // Step 3: Originate IPv6 route from R1 + r1.router + .create_origin6(vec![cidr!("2001:db8:1::/48")]) + .expect("originate IPv6 route on R1"); + + // Step 4: Verify R2 receives IPv6 route with link-local nexthop + let ipv6_prefix = Prefix::V6(cidr!("2001:db8:1::/48")); + wait_for!( + !r2.router.db.get_prefix_paths(&ipv6_prefix).is_empty(), + "R2 should receive IPv6 route from R1" + ); + + let ipv6_paths = r2.router.db.get_prefix_paths(&ipv6_prefix); + assert_eq!(ipv6_paths.len(), 1, "Should have exactly one path for IPv6"); + assert_eq!( + ipv6_paths[0].nexthop, r1_linklocal, + "IPv6 route nexthop should be R1's link-local address" + ); + + // Step 5: Originate routes from R2 in the opposite direction + r2.router + .create_origin4(vec![cidr!("10.2.0.0/24")]) + .expect("originate IPv4 route on R2"); + r2.router + .create_origin6(vec![cidr!("2001:db8:2::/48")]) + .expect("originate IPv6 route on R2"); + + // Step 6: Verify R1 receives both routes with R2's link-local nexthop + let r2_ipv4_prefix = Prefix::V4(cidr!("10.2.0.0/24")); + wait_for!( + !r1.router.db.get_prefix_paths(&r2_ipv4_prefix).is_empty(), + "R1 should receive IPv4 route from R2" + ); + + let r2_ipv4_paths = r1.router.db.get_prefix_paths(&r2_ipv4_prefix); + assert_eq!( + r2_ipv4_paths.len(), + 1, + "Should have exactly one path for R2's IPv4" + ); + assert_eq!( + r2_ipv4_paths[0].nexthop, r2_linklocal, + "R2's IPv4 route nexthop should be R2's link-local address" + ); + + let r2_ipv6_prefix = Prefix::V6(cidr!("2001:db8:2::/48")); + wait_for!( + !r1.router.db.get_prefix_paths(&r2_ipv6_prefix).is_empty(), + "R1 should receive IPv6 route from R2" + ); + + let r2_ipv6_paths = r1.router.db.get_prefix_paths(&r2_ipv6_prefix); + assert_eq!( + r2_ipv6_paths.len(), + 1, + "Should have exactly one path for R2's IPv6" + ); + assert_eq!( + r2_ipv6_paths[0].nexthop, r2_linklocal, + "R2's IPv6 route nexthop should be R2's link-local address" + ); + + // Step 7: Shutdown R1 and verify routes are withdrawn + r1.shutdown(); + + // Wait for sessions to tear down + wait_for_neq!( + session1.state(), + FsmStateKind::Established, + "R1 session should tear down" + ); + wait_for_neq!( + session2.state(), + FsmStateKind::Established, + "R2 session should tear down" + ); + + // Verify R1's routes are withdrawn from R2 + wait_for!( + r2.router.db.get_prefix_paths(&ipv4_prefix).is_empty(), + "R1's IPv4 route should be withdrawn from R2" + ); + wait_for!( + r2.router.db.get_prefix_paths(&ipv6_prefix).is_empty(), + "R1's IPv6 route should be withdrawn from R2" + ); + + // Topology cleanup happens via Drop +} diff --git a/bgp/src/unnumbered.rs b/bgp/src/unnumbered.rs new file mode 100644 index 00000000..0f220d22 --- /dev/null +++ b/bgp/src/unnumbered.rs @@ -0,0 +1,44 @@ +// 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::SocketAddr; + +/// Trait for managing unnumbered BGP sessions via NDP neighbor discovery. +/// +/// This trait provides the interface between BGP and the external NDP/neighbor +/// discovery system, enabling: +/// - Dispatcher to route incoming link-local connections to the correct session +/// - SessionRunner to query for discovered peer addresses on unnumbered interfaces +pub trait UnnumberedManager: Send + Sync { + /// Get the interface name for a given IPv6 scope_id. + /// + /// This is used by Dispatcher to route incoming link-local connections + /// to the correct unnumbered session based on the scope_id in the peer + /// address. + /// + /// # Arguments + /// * `scope_id` - The IPv6 scope_id (interface index) + /// + /// # Returns + /// * `Some(interface_name)` - Interface found for this scope_id + /// * `None` - No interface registered with this scope_id + fn get_interface_for_scope(&self, scope_id: u32) -> Option; + + /// Get the currently discovered neighbor for an interface. + /// + /// This is used by SessionRunner to actively query for peer addresses + /// when attempting connections on unnumbered interfaces. + /// + /// # Arguments + /// * `interface` - The interface name (e.g., "eth0") + /// + /// # Returns + /// * `Ok(Some(SocketAddr))` - Neighbor discovered at this address + /// * `Ok(None)` - No neighbor discovered yet + /// * `Err` - Interface not found or not IPv6 + fn get_neighbor_for_interface( + &self, + interface: &str, + ) -> Result, Box>; +} diff --git a/bgp/src/unnumbered_mock.rs b/bgp/src/unnumbered_mock.rs new file mode 100644 index 00000000..e1efb60b --- /dev/null +++ b/bgp/src/unnumbered_mock.rs @@ -0,0 +1,171 @@ +// 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/. + +//! Mock implementation of UnnumberedManager for testing. +//! +//! This module provides a controllable NDP state simulator that allows tests to: +//! - Simulate peer discovery on interfaces +//! - Trigger peer expiry +//! - Map scope_id to interface names (for Dispatcher routing) +//! - Verify SessionRunner queries NDP state correctly + +use crate::unnumbered::UnnumberedManager; +use mg_common::lock; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +/// Mock implementation of UnnumberedManager for testing. +/// +/// This simulates the UnnumberedManagerNdp's interface, +/// allowing tests to control when peers are discovered, expired, and +/// how scope_id maps to interface names. +#[derive(Clone)] +pub struct UnnumberedManagerMock { + /// Map from interface name to discovered peer address. + /// None means no peer discovered yet or peer has expired. + discoveries: Arc>>>, + + /// Map from scope_id to interface name. + /// Used by Dispatcher to route incoming link-local connections. + scope_map: Arc>>, +} + +impl UnnumberedManagerMock { + /// Create a new mock UnnumberedManager. + pub fn new() -> Arc { + Arc::new(Self { + discoveries: Arc::new(Mutex::new(HashMap::new())), + scope_map: Arc::new(Mutex::new(HashMap::new())), + }) + } + + /// Register an interface with a scope_id. + /// + /// This simulates adding an interface to the NDP manager. + /// The interface starts with no discovered peer. + pub fn register_interface(&self, interface: String, scope_id: u32) { + lock!(self.discoveries).insert(interface.clone(), None); + lock!(self.scope_map).insert(scope_id, interface); + } + + /// Unregister an interface. + /// + /// This simulates removing an interface from the NDP manager. + pub fn unregister_interface(&self, interface: &str) -> Option { + lock!(self.discoveries).remove(interface); + + // Find and remove the scope_id mapping + let mut scope_map = lock!(self.scope_map); + let scope_id = scope_map + .iter() + .find(|(_, iface)| iface.as_str() == interface) + .map(|(id, _)| *id); + + if let Some(id) = scope_id { + scope_map.remove(&id); + } + + scope_id + } + + /// Simulate NDP discovering a peer on an interface. + /// + /// # Arguments + /// * `interface` - The interface name + /// * `peer` - The discovered peer address (should be link-local with scope_id set) + /// + /// # Returns + /// `Ok(())` if interface is registered, `Err(())` if not. + #[allow(clippy::result_unit_err)] + pub fn discover_peer( + &self, + interface: &str, + peer: SocketAddr, + ) -> Result<(), ()> { + let mut discoveries = lock!(self.discoveries); + if let Some(entry) = discoveries.get_mut(interface) { + *entry = Some(peer); + Ok(()) + } else { + Err(()) + } + } + + /// Simulate NDP peer expiring on an interface. + /// + /// # Returns + /// `Ok(previous_peer)` if interface was registered, `Err(())` if not. + #[allow(clippy::result_unit_err)] + pub fn expire_peer( + &self, + interface: &str, + ) -> Result, ()> { + let mut discoveries = lock!(self.discoveries); + if let Some(entry) = discoveries.get_mut(interface) { + Ok(entry.take()) + } else { + Err(()) + } + } + + /// Get the currently discovered peer for an interface. + /// + /// Returns `None` if no peer has been discovered or if the peer has expired. + /// This simulates querying `UnnumberedManagerNdp::get_neighbor_for_interface()`. + pub fn get_neighbor(&self, interface: &str) -> Option { + lock!(self.discoveries).get(interface).and_then(|opt| *opt) + } + + /// Get the interface name for a given scope_id. + /// + /// This simulates querying `UnnumberedManagerNdp::get_interface_for_scope()`. + /// Used by Dispatcher to route incoming link-local connections. + pub fn get_interface_for_scope(&self, scope_id: u32) -> Option { + lock!(self.scope_map).get(&scope_id).cloned() + } + + /// Get all registered interfaces. + /// + /// Returns a list of (interface_name, scope_id, discovered_peer). + pub fn get_all_interfaces(&self) -> Vec<(String, u32, Option)> { + let discoveries = lock!(self.discoveries); + let scope_map = lock!(self.scope_map); + + discoveries + .iter() + .filter_map(|(iface, peer)| { + // Find scope_id for this interface + scope_map + .iter() + .find(|(_, i)| i.as_str() == iface) + .map(|(scope_id, _)| (iface.clone(), *scope_id, *peer)) + }) + .collect() + } +} + +/// Implement UnnumberedManager trait for use in tests. +/// +/// This allows UnnumberedManagerMock to be used as the unnumbered_manager parameter +/// when creating unnumbered BGP sessions in tests. +impl UnnumberedManager for UnnumberedManagerMock { + fn get_interface_for_scope(&self, scope_id: u32) -> Option { + Self::get_interface_for_scope(self, scope_id) + } + + fn get_neighbor_for_interface( + &self, + interface: &str, + ) -> Result, Box> { + // UnnumberedManagerMock returns None for unregistered interfaces, + // but UnnumberedManager expects an error for invalid interfaces + let discoveries = lock!(self.discoveries); + if discoveries.contains_key(interface) { + Ok(discoveries.get(interface).and_then(|opt| *opt)) + } else { + Err(format!("Interface '{}' not registered", interface).into()) + } + } +} diff --git a/docs/bgp-unnumbered.md b/docs/bgp-unnumbered.md new file mode 100644 index 00000000..2d66e033 --- /dev/null +++ b/docs/bgp-unnumbered.md @@ -0,0 +1,395 @@ +# BGP Unnumbered Design and Implementation + +**Author**: Generated Documentation +**Last Updated**: 2026-01-26 +**Audience**: Developers working on or integrating with Maghemite's BGP unnumbered implementation + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Background: Why BGP Unnumbered?](#background-why-bgp-unnumbered) +3. [Architecture Overview](#architecture-overview) +4. [NDP Integration](#ndp-integration) +5. [BGP FSM Integration](#bgp-fsm-integration) +6. [Key Design Decisions](#key-design-decisions) +7. [References](#references) + +--- + +## Overview + +BGP unnumbered enables BGP peering over IPv6 link-local addresses without requiring globally routable IP addresses on interfaces. This is particularly useful for datacenter underlay networks where IP address allocation overhead is eliminated and the same link-local address can be reused across multiple interfaces. + +Maghemite implements BGP unnumbered by integrating **NDP (Neighbor Discovery Protocol)** with the **BGP FSM (Finite State Machine)**: +- NDP discovers peer link-local addresses on configured interfaces +- BGP FSM manages session lifecycle based on discovered peers +- Sessions persist through neighbor changes and reconnect automatically + +--- + +## Background: Why BGP Unnumbered? + +### Traditional Numbered BGP + +In traditional BGP, each peer is identified by a globally routable IP address: + +``` +Router A (192.0.2.1) ←→ Router B (192.0.2.2) +``` + +**Challenges**: +- Requires unique IP subnet per link (wasteful in large fabrics) +- Configuration overhead for address assignment +- IP address exhaustion in large topologies + +### BGP Unnumbered Solution + +With BGP unnumbered, peers use IPv6 link-local addresses: + +``` +Router A (fe80::1%eth0) ←→ Router B (fe80::2%eth0) +``` + +**Benefits**: +- No IP address allocation needed (link-local addresses are self-assigned) +- Same IP can be reused on every interface +- Simplified configuration (just specify interface name) + +**Key Challenges Addressed**: +1. **Discovery mechanism**: How do we find the peer's link-local address? → NDP +2. **Session mapping**: How do we route incoming connections to the correct session? → scope_id lookup +3. **Nexthop resolution**: How do we specify which interface to use for routing? → nexthop_interface in Path + +--- + +## Architecture Overview + +### High-Level Component Diagram + +``` +┌─────────────────────────────────────────────────────────────┐ +│ UnnumberedManagerNdp │ +│ ┌──────────────────┐ ┌─────────────────────────────┐ │ +│ │ NDP Manager │ │ scope_id → interface map │ │ +│ │ (per-interface │ │ (for Dispatcher routing) │ │ +│ │ discovery) │ └─────────────────────────────┘ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ │ + │ NDP queries │ scope_id lookup + ▼ ▼ + ┌───────────────┐ ┌───────────────┐ + │ SessionRunner │ │ Dispatcher │ + │ (BGP FSM) │ │ (connection │ + │ │ │ acceptor) │ + └───────────────┘ └───────────────┘ + │ │ + │ initiate_connection() │ accept() + ▼ ▼ + ┌──────────────────────────────────────────┐ + │ TCP Connection (over link-local) │ + └──────────────────────────────────────────┘ +``` + +### Component Responsibilities + +| Component | Purpose | Cardinality | +|-----------|---------|-------------| +| **NdpManager** | Top-level manager for all unnumbered interfaces | 1 per daemon | +| **InterfaceNdpManager** | Per-interface NDP discovery (tx/rx loops) | 1 per unnumbered interface | +| **UnnumberedManagerNdp** | Bridge between NDP and BGP, maintains scope_id mappings | 1 per daemon | +| **SessionRunner (FSM)** | BGP state machine, queries NDP for peer discovery | 1 per unnumbered peer | +| **Dispatcher** | Accepts incoming connections, uses scope_id to route to correct FSM | 1 per listening address | + +--- + +## NDP Integration + +### NDP Protocol Overview + +NDP (RFC 4861) is the IPv6 equivalent of ARP. For BGP unnumbered, we use: +- **Router Solicitation (RS)**: "Is there a router on this link?" +- **Router Advertisement (RA)**: "I'm a router at fe80::X" + +### NdpManager Architecture + +**NdpManager** manages multiple interfaces: +```rust +pub struct NdpManager { + interfaces: RwLock>>, + log: Logger, +} +``` + +**InterfaceNdpManager** handles per-interface discovery: +```rust +pub struct InterfaceNdpManager { + _tx_thread: Arc, // Sends RA/RS every 5s + _rx_thread: Arc, // Receives RA/RS + inner: InterfaceNdpManagerInner, +} +``` + +**Key Design**: ManagedThread typestate pattern ensures: +- Threads can't be started twice (Ready → Running transition) +- Automatic shutdown signaling (Arc) +- Clean thread join on drop (deterministic cleanup) + +**NDP Cache**: Single-entry cache per interface +```rust +neighbor_router: Arc>> +``` + +Only the most recently received RA is kept. Expiry is checked based on time since reception and router lifetime from the RA message. + +### UnnumberedManagerNdp: The Bridge + +Connects NDP discovery with BGP session management: + +```rust +pub struct UnnumberedManagerNdp { + routers: Arc>>>>, + ndp_mgr: Arc, + interface_scope_map: Mutex>, // scope_id → interface + log: Logger, +} +``` + +**Key Operations**: + +1. **add_neighbor**: Start NDP discovery and create BGP session +2. **get_neighbor_for_interface**: Query discovered peer (used by FSM for connection attempts) +3. **get_interface_for_scope**: Map scope_id → interface (used by Dispatcher for routing incoming connections) + +**Critical Detail**: scope_id is the interface index, used to disambiguate link-local addresses: +- `fe80::1%2` (scope_id=2, eth0) ≠ `fe80::1%3` (scope_id=3, eth1) +- Without scope_id, the same link-local IP would be ambiguous + +--- + +## BGP FSM Integration + +### PeerId: Unified Session Indexing + +**Problem**: How do we index sessions when some use IP addresses and others use interface names? + +**Solution**: PeerId enum +```rust +pub enum PeerId { + Ip(IpAddr), // Numbered peers: "192.0.2.1" + Interface(String), // Unnumbered peers: "eth0" +} +``` + +**Why this works**: +- Unnumbered sessions are indexed by **interface name** (stable) +- Not indexed by **link-local IP** (dynamic, discovered via NDP) +- Dispatcher maps incoming scope_id → interface → SessionRunner + +### Persistent FSM Design + +**Design Philosophy**: Unnumbered sessions behave like numbered sessions. + +**Key Change from Initial Implementation**: +- **Before**: Session created only when NDP discovers peer (one-shot) +- **After**: Session created when peer is configured (persistent) + +**Why This Matters**: +1. **Consistency**: Unnumbered sessions have the same lifecycle as numbered sessions +2. **Testability**: Can test FSM without real NDP (use UnnumberedManagerMock) +3. **Resilience**: Session persists through NDP neighbor changes +4. **RFC Compliance**: FSM state transitions follow RFC 4271, not NDP events + +### Connection Initiation + +FSM queries unnumbered manager for current peer address during connection attempts: + +```rust +fn initiate_connection(&self) -> Result<(), Error> { + let peer_addr = if let Some(unnumbered_mgr) = &self.unnumbered_manager { + match unnumbered_mgr.get_neighbor_for_interface(interface) { + Ok(Some(addr)) => addr, + Ok(None) => { + // No NDP neighbor discovered yet + // Treat as async connection failure + // FSM will cycle back to Idle on ConnectRetryTimer + return Ok(()); + } + Err(e) => { + error!(self.log, "NDP query failed: {}", e); + return Ok(()); + } + } + } else { + self.peer_config.host // Numbered peer + }; + + // Proceed with connection attempt +} +``` + +**Behavior**: +- **NDP peer discovered**: Attempt connection to link-local address +- **No NDP peer**: Silent failure, FSM retries on timer +- Maintains RFC 4271 compliance (connection failures are transparent) + +### Connection Acceptance: Dispatcher Routing + +**Problem**: Incoming connection has link-local source address. How do we route it to the correct FSM? + +**Solution**: Use scope_id from SocketAddrV6 to look up interface name: + +```rust +// Dispatcher accepts connection +let peer_addr: SocketAddrV6 = accepted_connection.peer_addr(); +let scope_id = peer_addr.scope_id(); // Interface index + +// Query unnumbered manager +if let Some(interface) = unnumbered_mgr.get_interface_for_scope(scope_id) { + let key = PeerId::Interface(interface); + + // Look up session by interface name + if let Some(session_endpoint) = peer_to_session.get(&key) { + session_endpoint.event_tx.send(FsmEvent::BgpOpen(connection, peer_addr)); + } +} +``` + +**Flow**: +``` +Accept connection from fe80::2%3 + │ + ├─ Extract scope_id = 3 + ├─ Query: get_interface_for_scope(3) → "eth1" + ├─ Lookup: peer_to_session[PeerId::Interface("eth1")] + └─ Route connection to SessionRunner for eth1 +``` + +### Peer Validation + +FSM validates incoming connection source matches NDP-discovered peer: +- **Security**: Only accept connections from NDP-discovered peers +- **Collision detection**: Existing connection + new connection from same IP = collision +- **Session stability**: Ignore connections from expired/changed peers + +--- + +## Key Design Decisions + +### 1. Persistent FSM, Not Event-Driven + +**Decision**: Create FSM when peer is configured, not when NDP discovers peer. + +**Rationale**: +- FSM lifecycle matches numbered sessions (consistency) +- FSM controls its own state transitions (proper state machine) +- Session configuration persists even if NDP peer expires +- Testable with mock UnnumberedManager + +**Alternative Rejected**: Create FSM only when NDP discovers peer +- Problem: Peer expiry destroys FSM, losing session state +- Problem: NDP events driving FSM violates state machine principles + +### 2. Interface Name as Session Key + +**Decision**: Index sessions by `PeerId::Interface(String)`, not `PeerId::Ip(IpAddr)`. + +**Rationale**: +- Interface name is **stable** (doesn't change when peer expires/changes) +- Link-local IP is **dynamic** (discovered via NDP) +- Enables session persistence through NDP changes +- Supports same link-local IP on multiple interfaces (disambiguated by interface name) + +### 3. Nexthop Interface in Path + +**Decision**: Add `nexthop_interface: Option` to `Path` struct. + +**Rationale**: +- BGP is source of truth for nexthop + interface binding +- Allows same link-local nexthop on multiple interfaces +- Lower-half (mg-lower) gets complete nexthop information +- Survives NDP neighbor changes (stored in RIB) + +**Path Structure**: +```rust +pub struct Path { + pub nexthop: IpAddr, + + /// Interface binding for nexthop resolution. + /// Only populated for BGP unnumbered sessions. + #[serde(skip_serializing_if = "Option::is_none", default)] + #[schemars(skip)] // Hidden from OpenAPI for backwards compat + pub nexthop_interface: Option, + + pub bgp: Option, + // ... +} +``` + +**Example**: +```rust +// Route via unnumbered peer on eth0 +Path { + nexthop: IpAddr::V6(fe80::2), + nexthop_interface: Some("eth0".to_string()), + // ... +} +``` + +### 4. UnnumberedManager Trait for Testability + +**Decision**: Define `UnnumberedManager` trait with two implementations: +- `UnnumberedManagerNdp`: Production (real NDP) +- `UnnumberedManagerMock`: Testing (simulated NDP) + +**Benefits**: +- BGP tests don't require real network interfaces +- Tests can control NDP state changes explicitly +- Tests verify FSM behavior independent of NDP implementation + +### 5. ManagedThread Typestate Pattern + +**Decision**: Use typestate pattern for thread lifecycle management. + +**Benefits**: +- **Type safety**: Can't start thread twice (Ready → Running is one-way) +- **Automatic cleanup**: Drop sets flag and joins thread +- **No leaks**: Thread always joined when ManagedThread is dropped +- **Explicit lifecycle**: State transitions are visible in types + +--- + +## References + +### RFCs + +- **RFC 4271**: A Border Gateway Protocol 4 (BGP-4) +- **RFC 4861**: Neighbor Discovery for IP version 6 (IPv6) +- **RFC 5549**: Advertising IPv4 Network Layer Reachability Information with an IPv6 Next Hop +- **RFC 8950**: Advertising IPv4 NLRI with an IPv6 Next Hop (BGP IPv4 over IPv6) + +### Related Documentation + +- [BGP Architecture Guide](bgp-architecture.md) - BGP FSM implementation details +- [README.md](../README.md) - Maghemite overview +- [OpenAPI Specification](../openapi/mg-admin/mg-admin-latest.json) - REST API reference + +### Glossary + +- **BGP Unnumbered**: BGP peering over IPv6 link-local addresses without global IPs +- **Link-Local Address**: IPv6 address (fe80::/10) valid only on a specific link +- **scope_id**: Interface index disambiguating link-local addresses (e.g., %eth0) +- **NDP**: Neighbor Discovery Protocol (RFC 4861), IPv6 equivalent of ARP +- **Router Advertisement (RA)**: ICMPv6 message announcing router presence on a link +- **Router Solicitation (RS)**: ICMPv6 message requesting router advertisements +- **PeerId**: Session key, either IP address (numbered) or interface name (unnumbered) +- **Path**: Route representation in RIB, includes nexthop and optional interface +- **ManagedThread**: Typestate pattern for thread lifecycle management +- **UnnumberedManager**: Trait abstracting NDP queries for FSM +- **Persistent FSM**: Session created at config time, not discovery time + +--- + +**End of Document** diff --git a/falcon-lab/Cargo.toml b/falcon-lab/Cargo.toml new file mode 100644 index 00000000..199f0a37 --- /dev/null +++ b/falcon-lab/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "falcon-lab" +version = "0.1.0" +edition = "2024" + +[dependencies] +libfalcon.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +mg-admin-client.workspace = true +ddm-admin-client.workspace = true +tokio.workspace = true +slog.workspace = true +dpd-client.workspace = true +clap.workspace = true +oxnet.workspace = true +oxide-tokio-rt.workspace = true +bgp.workspace = true +colored.workspace = true +rdb-types.workspace = true diff --git a/falcon-lab/src/bgp.rs b/falcon-lab/src/bgp.rs new file mode 100644 index 00000000..aa3bf451 --- /dev/null +++ b/falcon-lab/src/bgp.rs @@ -0,0 +1,50 @@ +//! BGP utilities + +use mg_admin_client::types::{ + ImportExportPolicy4, ImportExportPolicy6, Ipv4UnicastConfig, + Ipv6UnicastConfig, UnnumberedNeighbor, +}; + +pub fn basic_unnumbered_neighbor( + name: &str, + group: &str, + interface: &str, + local_asn: u32, + act_as_a_default_ipv6_router: u16, +) -> UnnumberedNeighbor { + UnnumberedNeighbor { + asn: local_asn, + act_as_a_default_ipv6_router, + communities: Vec::default(), + connect_retry: 5, + delay_open: 0, + enforce_first_as: false, + group: group.to_owned(), + hold_time: 6, + idle_hold_time: 0, + interface: interface.to_string(), + keepalive: 2, + local_pref: None, + md5_auth_key: None, + min_ttl: None, + multi_exit_discriminator: None, + name: name.to_string(), + passive: false, + remote_asn: None, + resolution: 100, + vlan_id: None, + ipv4_unicast: Some(Ipv4UnicastConfig { + import_policy: ImportExportPolicy4::NoFiltering, + export_policy: ImportExportPolicy4::NoFiltering, + nexthop: None, + }), + ipv6_unicast: Some(Ipv6UnicastConfig { + import_policy: ImportExportPolicy6::NoFiltering, + export_policy: ImportExportPolicy6::NoFiltering, + nexthop: None, + }), + connect_retry_jitter: None, + deterministic_collision_resolution: false, + idle_hold_jitter: None, + } +} diff --git a/falcon-lab/src/ddm.rs b/falcon-lab/src/ddm.rs new file mode 100644 index 00000000..2b52d92d --- /dev/null +++ b/falcon-lab/src/ddm.rs @@ -0,0 +1,36 @@ +//! DDM machinery + +#![allow(dead_code)] + +use crate::{dendrite::DendriteNode, illumos::IllumosNode}; +use anyhow::Result; +use ddm_admin_client::Client; +use libfalcon::{NodeRef, Runner}; +use std::net::IpAddr; + +#[derive(Copy, Clone)] +pub struct DdmNode(pub NodeRef); + +impl DdmNode { + pub async fn run_ddm(&self, d: &Runner) -> Result<()> { + d.exec( + self.0, + "chmod +x /opt/cargo-bay/ddmd && \ + /opt/cargo-bay/ddmd &> /tmp/ddm.log &", + ) + .await?; + Ok(()) + } + + pub async fn client(&self, d: &Runner, addr: IpAddr) -> Result { + Ok(Client::new(&format!("http://{addr}:8000"), d.log.clone())) + } + + pub fn illumos(&self) -> IllumosNode { + IllumosNode(self.0) + } + + pub fn dendrite(&self) -> DendriteNode { + DendriteNode(self.0) + } +} diff --git a/falcon-lab/src/dendrite.rs b/falcon-lab/src/dendrite.rs new file mode 100644 index 00000000..aa540a57 --- /dev/null +++ b/falcon-lab/src/dendrite.rs @@ -0,0 +1,122 @@ +//! Dendrite machinery + +#![allow(dead_code)] + +use crate::illumos::IllumosNode; +use anyhow::{Result, anyhow}; +use dpd_client::{ + Client, + types::{LinkCreate, LinkId, PortId, PortSpeed}, +}; +use libfalcon::{NodeRef, Runner}; +use slog::{Logger, debug, info}; +use std::{net::IpAddr, sync::Arc, time::Duration}; +use tokio::time::{Instant, sleep}; + +#[derive(Copy, Clone)] +pub struct DendriteNode(pub NodeRef); + +impl DendriteNode { + pub fn name(&self, d: &Runner) -> String { + d.get_node(self.0).name.clone() + } + + pub async fn client(&self, d: &Runner, addr: IpAddr) -> Result { + let client_state = dpd_client::ClientState { + tag: String::default(), + log: d.log.clone(), + }; + Ok(Client::new( + &format!("http://{addr}:{}", dpd_client::default_port()), + client_state, + )) + } + + pub async fn npuvm( + self, + d: Arc, + front_ports: usize, + rear_ports: usize, + npuvm_commit: String, + dendrite_commit: Option, + sidecar_lite_commit: Option, + ) -> Result<()> { + const BUILDOMAT_URL: &str = + "https://buildomat.eng.oxide.computer/public/file/oxidecomputer/"; + info!(d.log, "{}: setting up npuvm", self.name(&d)); + d.exec( + self.0, + &format!( + "curl --retry 5 -OL \ + {BUILDOMAT_URL}/softnpu/image/{npuvm_commit}/npuvm" + ), + ) + .await?; + d.exec(self.0, "chmod +x npuvm").await?; + d.exec( + self.0, + &format!( + "./npuvm install \ + --front-ports {front_ports} \ + --rear-ports {rear_ports} \ + --pkt-source vioif0 \ + {} {}", + dendrite_commit + .map(|x| format!("--dendrite-commit {x}")) + .unwrap_or_default(), + sidecar_lite_commit + .map(|x| format!("--sidecar-lite-commit {x}")) + .unwrap_or_default(), + ), + ) + .await?; + d.exec( + self.0, + "/root/scadm propolis load-program /root/libsidecar_lite.so", + ) + .await?; + Ok(()) + } + + pub fn illumos(&self) -> IllumosNode { + IllumosNode(self.0) + } +} + +pub async fn softnpu_link_create(c: &Client, name: &str) -> Result<()> { + let port = PortId::Qsfp(name.parse()?); + let link = LinkId(0); + c.link_create( + &port, + &LinkCreate { + autoneg: false, + fec: None, + kr: false, + lane: Some(link), + speed: PortSpeed::Speed100G, + tx_eq: None, + }, + ) + .await?; + c.link_enabled_set(&port, &link, true).await?; + Ok(()) +} + +pub async fn wait_for_dpd( + c: &Client, + timeout: Duration, + log: &Logger, +) -> Result<()> { + let start = Instant::now(); + loop { + match c.dpd_uptime().await { + Ok(_) => return Ok(()), + Err(e) => debug!(log, "wait for dpd: {e}"), + } + if start.elapsed() >= timeout { + break; + } + sleep(Duration::from_secs(1)).await + } + Err(anyhow!("timeout waiting for dpd")) +} diff --git a/falcon-lab/src/eos.rs b/falcon-lab/src/eos.rs new file mode 100644 index 00000000..410bf91d --- /dev/null +++ b/falcon-lab/src/eos.rs @@ -0,0 +1,167 @@ +//! Arista EOS machinery + +#![allow(dead_code)] + +use crate::linux::LinuxNode; +use anyhow::{Result, anyhow}; +use colored::Colorize; +use libfalcon::{NodeRef, Runner}; +use oxnet::{Ipv4Net, Ipv6Net}; +use serde::Deserialize; +use slog::info; +use std::collections::HashMap; + +#[derive(Copy, Clone)] +pub struct EosNode(pub NodeRef); + +impl EosNode { + pub fn name(&self, d: &Runner) -> String { + d.get_node(self.0).name.clone() + } + + pub async fn wait_for_init(&self, d: &Runner) -> Result<()> { + info!(d.log, "waiting for ceos to initialize"); + let mut retries = 60usize; + loop { + if retries == 0 { + break; + } + retries = retries.saturating_sub(1); + let status = d + .exec( + self.0, + "docker inspect ceos --format '{{.State.Status}}'", + ) + .await?; + + let version = self.shell(d, "show version").await?; + + if status.contains("running") && version.contains("Arista cEOSLab") + { + return Ok(()); + } + } + Err(anyhow!("ceos wait for init timeout")) + } + + pub async fn shell(&self, d: &Runner, script: &str) -> Result { + info!( + d.log, + "{}: executing eos script {}", + self.name(d), + script.dimmed() + ); + + let response = d + .exec(self.0, &format!("docker exec ceos Cli -c '{script}'")) + .await?; + + Ok(response) + } + + pub fn linux(&self) -> LinuxNode { + LinuxNode(self.0) + } + + /// Get BGP IPv4 imported prefixes from EOS. + pub async fn bgp_ipv4_imported( + &self, + d: &Runner, + ) -> Result { + let output = self.shell(d, "show ip bgp | json").await?; + let response: BgpIpv4Response = serde_json::from_str(&output)?; + Ok(response) + } + + /// Get BGP IPv6 imported prefixes from EOS. + pub async fn bgp_ipv6_imported( + &self, + d: &Runner, + ) -> Result { + let output = self.shell(d, "show ipv6 bgp | json").await?; + let response: BgpIpv6Response = serde_json::from_str(&output)?; + Ok(response) + } +} + +/// Minimal representation of `show ip bgp | json` output. +/// Only captures destination prefix and nexthop. +#[derive(Debug, Deserialize)] +pub struct BgpIpv4Response { + pub vrfs: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv4Vrf { + pub bgp_route_entries: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv4RouteEntry { + pub bgp_route_paths: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv4RoutePath { + #[serde(default)] + pub next_hop: String, +} + +impl BgpIpv4Response { + /// Returns all imported routes (those with a non-empty nexthop) from all VRFs. + pub fn all(&self) -> impl Iterator { + self.vrfs.values().flat_map(|vrf| { + vrf.bgp_route_entries.iter().flat_map(|(prefix, entry)| { + entry + .bgp_route_paths + .iter() + .filter(|path| !path.next_hop.is_empty()) + .map(move |path| (prefix, path)) + }) + }) + } +} + +/// Minimal representation of `show ipv6 bgp | json` output. +/// Only captures destination prefix and nexthop. +#[derive(Debug, Deserialize)] +pub struct BgpIpv6Response { + pub vrfs: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv6Vrf { + pub bgp_route_entries: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv6RouteEntry { + pub bgp_route_paths: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BgpIpv6RoutePath { + #[serde(default)] + pub next_hop: String, +} + +impl BgpIpv6Response { + /// Returns all imported routes (those with a non-empty nexthop) from all VRFs. + pub fn all(&self) -> impl Iterator { + self.vrfs.values().flat_map(|vrf| { + vrf.bgp_route_entries.iter().flat_map(|(prefix, entry)| { + entry + .bgp_route_paths + .iter() + .filter(|path| !path.next_hop.is_empty()) + .map(move |path| (prefix, path)) + }) + }) + } +} diff --git a/falcon-lab/src/frr.rs b/falcon-lab/src/frr.rs new file mode 100644 index 00000000..066f3da8 --- /dev/null +++ b/falcon-lab/src/frr.rs @@ -0,0 +1,148 @@ +//! FRR machinery + +#![allow(dead_code)] + +use crate::linux::LinuxNode; +use anyhow::{Context, Result}; +use colored::Colorize; +use libfalcon::{NodeRef, Runner}; +use oxnet::{Ipv4Net, Ipv6Net}; +use serde::Deserialize; +use slog::info; +use std::collections::HashMap; +use std::net::IpAddr; +use std::time::Duration; +use tokio::time::sleep; + +#[derive(Copy, Clone)] +pub struct FrrNode(pub NodeRef); + +impl FrrNode { + pub fn name(&self, d: &Runner) -> String { + d.get_node(self.0).name.clone() + } + + pub async fn enable_daemons( + &self, + d: &Runner, + daemons: &[&str], + ) -> Result<()> { + for name in daemons { + info!(d.log, "{}: enabling frr daemon {name}", self.name(d)); + d.exec( + self.0, + &format!("sed -i 's/{name}=no/{name}=yes/g' /etc/frr/daemons"), + ) + .await?; + } + d.exec(self.0, "systemctl restart frr").await?; + // XXX do better than arbitrary wait + sleep(Duration::from_secs(5)).await; + Ok(()) + } + + pub async fn install(&self, d: &Runner) -> Result<()> { + info!(d.log, "{}: installing frr", self.name(d)); + d.exec(self.0, "apt-get -y update && apt-get -y install frr") + .await + .context("apt install frr failed")?; + Ok(()) + } + + pub fn linux(&self) -> LinuxNode { + LinuxNode(self.0) + } + + /// Execute a vtysh command and return the output. + pub async fn shell(&self, d: &Runner, script: &str) -> Result { + info!( + d.log, + "{}: executing frr script {}", + self.name(d), + script.dimmed() + ); + let args = script + .lines() + .map(|l| format!("-c '{l}'")) + .collect::>() + .join(" "); + let output = d + .exec(self.0, &format!("vtysh {args}")) + .await + .context("vtysh shell failed")?; + Ok(output) + } + + /// Get BGP IPv4 imported prefixes from FRR. + pub async fn bgp_ipv4_imported( + &self, + d: &Runner, + ) -> Result { + let output = self.shell(d, "show ip bgp json").await?; + let response: FrrBgpIpv4Response = serde_json::from_str(&output)?; + Ok(response) + } + + /// Get BGP IPv6 imported prefixes from FRR. + pub async fn bgp_ipv6_imported( + &self, + d: &Runner, + ) -> Result { + let output = self.shell(d, "show bgp json").await?; + let response: FrrBgpIpv6Response = serde_json::from_str(&output)?; + Ok(response) + } +} + +/// Minimal representation of FRR `show ip bgp json` output. +/// Only captures destination prefix and nexthop. +#[derive(Debug, Deserialize)] +pub struct FrrBgpIpv4Response { + pub routes: HashMap>, +} + +impl FrrBgpIpv4Response { + /// Returns all imported routes (those with a non-unspecified nexthop) from the response. + pub fn all(&self) -> impl Iterator { + self.routes.iter().flat_map(|(prefix, paths)| { + paths.iter().flat_map(move |path| { + path.nexthops + .iter() + .filter(|nh| !nh.ip.is_unspecified()) + .map(move |nh| (prefix, nh)) + }) + }) + } +} + +/// Minimal representation of FRR `show bgp json` output (IPv6). +/// Only captures destination prefix and nexthop. +#[derive(Debug, Deserialize)] +pub struct FrrBgpIpv6Response { + pub routes: HashMap>, +} + +impl FrrBgpIpv6Response { + /// Returns all imported routes (those with a non-unspecified nexthop) from the response. + pub fn all(&self) -> impl Iterator { + self.routes.iter().flat_map(|(prefix, paths)| { + paths.iter().flat_map(move |path| { + path.nexthops + .iter() + .filter(|nh| !nh.ip.is_unspecified()) + .map(move |nh| (prefix, nh)) + }) + }) + } +} + +#[derive(Debug, Deserialize)] +pub struct FrrBgpRoutePath { + #[serde(default)] + pub nexthops: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct FrrBgpNexthop { + pub ip: IpAddr, +} diff --git a/falcon-lab/src/illumos.rs b/falcon-lab/src/illumos.rs new file mode 100644 index 00000000..fdb819db --- /dev/null +++ b/falcon-lab/src/illumos.rs @@ -0,0 +1,100 @@ +//! illumos machinery + +use anyhow::{Result, anyhow}; +use libfalcon::{NodeRef, Runner}; +use slog::{debug, error}; +use std::{net::IpAddr, time::Duration}; +use tokio::time::{Instant, sleep}; + +#[derive(Copy, Clone)] +pub struct IllumosNode(pub NodeRef); + +impl IllumosNode { + pub async fn ip(&self, d: &Runner, addrobj: &str) -> Result { + let ip = d + .exec(self.0, &format!("ipadm show-addr {addrobj} -p -o addr")) + .await?; + // handle link locals with percent scopes + if let Some((ip, _)) = ip.split_once("%") { + return ip.parse().map_err(|e| anyhow!("invalid ip: {ip}: {e}")); + } + let ipnet: oxnet::IpNet = + ip.parse().map_err(|e| anyhow!("invalid ip: {ip}: {e}"))?; + Ok(ipnet.addr()) + } + + pub async fn dhcp(&self, d: &Runner, addrobj: &str) -> Result { + d.exec(self.0, &format!("ipadm create-addr -T dhcp {addrobj}")) + .await?; + d.exec(self.0, "echo 'nameserver 1.1.1.1' > /etc/resolv.conf") + .await?; + let mut retries = 10usize; + loop { + match self.ip(d, addrobj).await { + Ok(addr) => return Ok(addr), + Err(e) => { + if retries > 0 { + debug!(d.log, "error waiting for dhcp address: {e}"); + retries = retries.saturating_sub(1); + sleep(Duration::from_secs(1)).await; + } else { + error!(d.log, "error waiting for dhcp address: {e}"); + break; + } + } + } + } + Err(anyhow!("dhcp timed out")) + } + + pub async fn addrconf(&self, d: &Runner, addrobj: &str) -> Result { + d.exec(self.0, &format!("ipadm create-addr -T addrconf {addrobj}")) + .await?; + let mut retries = 10usize; + loop { + match self.ip(d, addrobj).await { + Ok(addr) => return Ok(addr), + Err(e) => { + if retries > 0 { + debug!( + d.log, + "error waiting for addrconf address: {e}" + ); + retries = retries.saturating_sub(1); + sleep(Duration::from_secs(1)).await; + } else { + error!( + d.log, + "error waiting for addrconf address: {e}" + ); + break; + } + } + } + } + Err(anyhow!("addrconf timed out")) + } + + pub async fn wait_for_link( + &self, + d: &Runner, + name: &str, + timeout: Duration, + ) -> Result<()> { + let start = Instant::now(); + loop { + let result = d + .exec(self.0, &format!("dladm show-link {name} -p -o link")) + .await + .map_err(|e| anyhow!("error showing link {name}: {e}"))?; + if result.as_str() == name { + return Ok(()); + } + if start.elapsed() >= timeout { + break; + } + sleep(Duration::from_secs(1)).await + } + Err(anyhow!("timeout waiting for link {name}")) + } +} diff --git a/falcon-lab/src/linux.rs b/falcon-lab/src/linux.rs new file mode 100644 index 00000000..bac85aef --- /dev/null +++ b/falcon-lab/src/linux.rs @@ -0,0 +1,31 @@ +//! Linux machinery + +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use libfalcon::{NodeRef, Runner}; +use serde::Deserialize; +use std::net::IpAddr; + +#[derive(Copy, Clone)] +pub struct LinuxNode(pub NodeRef); + +impl LinuxNode { + pub async fn ip(&self, d: &Runner, link: &str) -> Result> { + let json = d.exec(self.0, &format!("ip j addr show {link}")).await?; + let ipaddr: IpAddrInfo = + serde_json::from_str(&json).context("parse ip addr json")?; + + Ok(ipaddr.addr_info.iter().map(|x| x.local).collect()) + } +} + +#[derive(Deserialize)] +struct IpAddrInfo { + addr_info: Vec, +} + +#[derive(Deserialize)] +struct AddrInfo { + local: IpAddr, +} diff --git a/falcon-lab/src/main.rs b/falcon-lab/src/main.rs new file mode 100644 index 00000000..e590b7a9 --- /dev/null +++ b/falcon-lab/src/main.rs @@ -0,0 +1,91 @@ +//! Falcon test lab + +use crate::test::{cleanup_unnumbered_test, run_trio_unnumbered_test}; +use clap::{Parser, Subcommand}; + +mod bgp; +mod ddm; +mod dendrite; +mod eos; +mod frr; +mod illumos; +mod linux; +mod mgd; +mod test; +mod topo; +mod util; + +#[derive(Debug, Parser)] +struct Cli { + #[clap(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Run(Run), + Cleanup(Cleanup), + Serial(Serial), +} + +#[derive(Debug, Parser)] +struct Run { + #[clap(subcommand)] + command: TestCommand, + + #[clap(long)] + no_cleanup: bool, + + #[clap(long, default_value = "fd2c726815cdb03c2687e1bf2912a9184905557b")] + npuvm_commit: String, + + #[clap(long)] + dendrite_commit: Option, + + #[clap(long)] + sidecar_lite_commit: Option, +} + +#[derive(Debug, Parser)] +struct Cleanup { + #[clap(subcommand)] + command: TestCommand, +} + +#[derive(Debug, Parser)] +struct Serial { + node: String, +} + +#[derive(Debug, Subcommand)] +enum TestCommand { + TrioUnnumbered, +} + +fn main() -> anyhow::Result<()> { + oxide_tokio_rt::run(run()) +} + +async fn run() -> anyhow::Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Run(cmd) => match cmd.command { + TestCommand::TrioUnnumbered => { + run_trio_unnumbered_test( + cmd.no_cleanup, + cmd.npuvm_commit.clone(), + cmd.dendrite_commit, + cmd.sidecar_lite_commit, + ) + .await? + } + }, + Command::Cleanup(cmd) => match cmd.command { + TestCommand::TrioUnnumbered => cleanup_unnumbered_test().await?, + }, + Command::Serial(cmd) => { + libfalcon::cli::console(&cmd.node, ".falcon".into()).await? + } + } + Ok(()) +} diff --git a/falcon-lab/src/mgd.rs b/falcon-lab/src/mgd.rs new file mode 100644 index 00000000..ed432b86 --- /dev/null +++ b/falcon-lab/src/mgd.rs @@ -0,0 +1,59 @@ +//! MGD machinery + +use crate::{ddm::DdmNode, dendrite::DendriteNode, illumos::IllumosNode}; +use anyhow::{Result, anyhow}; +use libfalcon::{NodeRef, Runner}; +use mg_admin_client::Client; +use slog::{Logger, debug}; +use std::{net::IpAddr, time::Duration}; +use tokio::time::{Instant, sleep}; + +#[derive(Copy, Clone)] +pub struct MgdNode(pub NodeRef); + +impl MgdNode { + pub async fn run_mgd(&self, d: &Runner) -> Result<()> { + d.exec( + self.0, + "chmod +x /opt/cargo-bay/mgd && \ + /opt/cargo-bay/mgd run &> /tmp/mgd.log &", + ) + .await?; + Ok(()) + } + + pub async fn client(&self, d: &Runner, addr: IpAddr) -> Result { + Ok(Client::new(&format!("http://{addr}:4676"), d.log.clone())) + } + + pub fn illumos(&self) -> IllumosNode { + IllumosNode(self.0) + } + + pub fn dendrite(&self) -> DendriteNode { + DendriteNode(self.0) + } + + pub fn ddm(&self) -> DdmNode { + DdmNode(self.0) + } +} + +pub async fn wait_for_mgd( + c: &Client, + timeout: Duration, + log: &Logger, +) -> Result<()> { + let start = Instant::now(); + loop { + match c.read_routers().await { + Ok(_) => return Ok(()), + Err(e) => debug!(log, "wait for mgd: {e}"), + } + if start.elapsed() >= timeout { + break; + } + sleep(Duration::from_secs(1)).await + } + Err(anyhow!("timeout waiting for mgd")) +} diff --git a/falcon-lab/src/test.rs b/falcon-lab/src/test.rs new file mode 100644 index 00000000..67e030b2 --- /dev/null +++ b/falcon-lab/src/test.rs @@ -0,0 +1,322 @@ +//! Tests + +#![allow(clippy::iter_nth_zero)] + +use crate::{ + bgp::basic_unnumbered_neighbor, + dendrite::{softnpu_link_create, wait_for_dpd}, + eos::EosNode, + frr::FrrNode, + mgd::wait_for_mgd, + topo::{Trio, trio}, + wait_for_eq, +}; +use anyhow::{Context, Result}; +use libfalcon::Runner; +use mg_admin_client::types::{FsmStateKind, Origin4, Origin6, Router}; +use rdb_types::AddressFamily; +use slog::info; +use std::{sync::Arc, time::Duration}; + +const TRIO_UNNUMBERED_TOPO_NAME: &str = "mgtriou"; +const OP_TIMEOUT: Duration = Duration::from_secs(10); + +pub async fn cleanup_unnumbered_test() -> Result<()> { + // dropping this with out persistent set will destroy + // the topo + let _topo = trio(TRIO_UNNUMBERED_TOPO_NAME)?; + Ok(()) +} + +pub async fn run_trio_unnumbered_test( + persistent: bool, + npuvm_commit: String, + dendrite_commit: Option, + sidecar_lite_commit: Option, +) -> Result<()> { + let Trio { + mut d, + ox, + cr1, + cr2, + } = trio(TRIO_UNNUMBERED_TOPO_NAME)?; + d.persistent = persistent; + + d.launch().await.context("launch failed")?; + + let ad = std::sync::Arc::new(d); + + let addr = ox.illumos().dhcp(&ad, "vioif1/dhcp").await?; + + // These take a minute, knock them out concurrently + let mut js = tokio::task::JoinSet::new(); + js.spawn(frr_setup(cr1, ad.clone())); + js.spawn(eos_setup(cr2, ad.clone())); + js.spawn(ox.dendrite().npuvm( + ad.clone(), + 2, + 0, + npuvm_commit, + dendrite_commit, + sidecar_lite_commit, + )); + for result in js.join_all().await.into_iter() { + result?; + } + + let mgd = ox.client(&ad, addr).await?; + let dpd = ox.dendrite().client(&ad, addr).await?; + + // Wait for dpd to start + wait_for_dpd(&dpd, OP_TIMEOUT, &ad.log).await?; + + for link in ["qsfp0", "qsfp1"] { + softnpu_link_create(&dpd, link) + .await + .context(format!("create {link}"))?; + } + + for link in ["tfportqsfp0_0", "tfportqsfp1_0"] { + ox.illumos().wait_for_link(&ad, link, OP_TIMEOUT).await?; + let addr = format!("{link}/ll"); + ox.illumos() + .addrconf(&ad, &addr) + .await + .context(format!("create {addr}"))?; + } + + ox.run_mgd(&ad).await?; + ox.ddm().run_ddm(&ad).await?; + + // Wait for mgd to start + wait_for_mgd(&mgd, OP_TIMEOUT, &ad.log).await?; + + let local_asn: u32 = 33; + + info!(ad.log, "adding BGP router to mgd"); + + mgd.create_router(&Router { + asn: local_asn, + graceful_shutdown: false, + id: 33, + listen: "[::]:179".to_owned(), + }) + .await + .context("mgd: create router")?; + + mgd.create_unnumbered_neighbor(&basic_unnumbered_neighbor( + "cr1", + "test", + "tfportqsfp0_0", + 33, + 0, + )) + .await + .context("mgd: create cr1 unnumbered neighbor")?; + + mgd.create_unnumbered_neighbor(&basic_unnumbered_neighbor( + "cr2", + "test", + "tfportqsfp1_0", + 33, + 1800, + )) + .await + .context("mgd: create cr2 unnumbered neighbor")?; + + mgd.create_origin4(&Origin4 { + asn: 33, + prefixes: vec!["4.5.6.0/24".parse().expect("parse ipv4 origin")], + }) + .await + .context("announce v4 prefix")?; + + mgd.create_origin6(&Origin6 { + asn: 33, + prefixes: vec!["fdee::/64".parse().expect("parse ipv6 origin")], + }) + .await + .context("announce v6 prefix")?; + + wait_for_eq!( + mgd.get_neighbors_v4(local_asn) + .await + .map(|x| x.into_inner().len()) + .unwrap_or(0), + 2, + "neighbors" + ); + + wait_for_eq!( + mgd.get_neighbors_v4(local_asn) + .await + .map(|x| x.into_inner().values().nth(0).map(|y| y.fsm_state)) + .unwrap_or(None), + Some(FsmStateKind::Established), + "first neighbor established" + ); + + wait_for_eq!( + mgd.get_rib_imported_v2(Some(&AddressFamily::Ipv4), None) + .await + .map(|x| x.len()) + .unwrap_or(0), + 1, + "imported ipv4 route" + ); + + wait_for_eq!( + mgd.get_rib_imported_v2(Some(&AddressFamily::Ipv4), None) + .await + .map(|x| x.values().nth(0).map(|x| x.len())) + .unwrap_or(None), + Some(2), + "ipv4 paths" + ); + + wait_for_eq!( + dpd.route_ipv4_list(None, None) + .await + .map(|x| x.items.len()) + .unwrap_or(0), + 1, + "dpd ipv4 routes" + ); + + wait_for_eq!( + mgd.get_rib_imported_v2(Some(&AddressFamily::Ipv6), None) + .await + .map(|x| x.len()) + .unwrap_or(0), + 1, + "imported ipv6 route" + ); + + wait_for_eq!( + mgd.get_rib_imported_v2(Some(&AddressFamily::Ipv6), None) + .await + .map(|x| x.values().nth(0).map(|x| x.len())) + .unwrap_or(None), + Some(2), + "ipv6 paths" + ); + + wait_for_eq!( + dpd.route_ipv6_list(None, None) + .await + .map(|x| x.items.len()) + .unwrap_or(0), + 1, + "dpd ipv6 routes" + ); + + wait_for_eq!( + cr1.bgp_ipv4_imported(&ad) + .await + .map(|x| x.all().count()) + .unwrap_or(0), + 1, + "cr1 imported ipv4 routes" + ); + + wait_for_eq!( + cr1.bgp_ipv6_imported(&ad) + .await + .map(|x| x.all().count()) + .unwrap_or(0), + 1, + "cr1 imported ipv6 routes" + ); + + wait_for_eq!( + cr2.bgp_ipv4_imported(&ad) + .await + .map(|x| x.all().count()) + .unwrap_or(0), + 1, + "cr2 imported ipv4 routes" + ); + + wait_for_eq!( + cr2.bgp_ipv6_imported(&ad) + .await + .map(|x| x.all().count()) + .unwrap_or(0), + 1, + "cr2 imported ipv6 routes" + ); + + info!(ad.log, "trio bgp unnumbered test passed 🎉"); + + Ok(()) +} + +async fn frr_setup(r: FrrNode, d: Arc) -> Result<()> { + const BASE_CONFIG: &str = " + configure + ip forwarding + ipv6 forwarding + ip route 1.2.3.0/24 null0 + ipv6 route fd99::/64 null0 + route-map PERMIT-ALL permit 10 + router bgp 44 + timers bgp 2 6 + neighbor enp0s8 interface remote-as external + neighbor enp0s8 timers connect 1 + address-family ipv4 unicast + network 1.2.3.0/24 + neighbor enp0s8 activate + neighbor enp0s8 route-map PERMIT-ALL out + neighbor enp0s8 route-map PERMIT-ALL in + exit-address-family + address-family ipv6 unicast + network fd99::/64 + neighbor enp0s8 activate + neighbor enp0s8 route-map PERMIT-ALL out + neighbor enp0s8 route-map PERMIT-ALL in + exit-address-family + exit + "; + + r.install(&d).await?; + r.enable_daemons(&d, &["bgpd"]).await?; + r.shell(&d, BASE_CONFIG).await?; + Ok(()) +} + +async fn eos_setup(r: EosNode, d: Arc) -> Result<()> { + const BASE_CONFIG: &str = " + enable + configure + ipv6 unicast-routing + ip routing ipv6 interfaces + ip routing + ip route 1.2.3.0/24 null0 + ipv6 route fd99::/64 null0 + interface et1 + no switchport + ipv6 enable + + router bgp 45 + router-id 1.2.3.1 + no bgp default ipv4-unicast + timers bgp 2 6 + neighbor ebgp peer group + neighbor ebgp remote-as 33 + neighbor interface Et1 peer-group ebgp + address-family ipv4 + neighbor ebgp activate + neighbor ebgp next-hop address-family ipv6 originate + network 1.2.3.0/24 + exit + address-family ipv6 + neighbor ebgp activate + neighbor ebgp next-hop address-family ipv6 originate + network fd99::/64 + exit + exit + "; + r.wait_for_init(&d).await?; + r.shell(&d, BASE_CONFIG).await?; + Ok(()) +} diff --git a/falcon-lab/src/topo.rs b/falcon-lab/src/topo.rs new file mode 100644 index 00000000..360ef716 --- /dev/null +++ b/falcon-lab/src/topo.rs @@ -0,0 +1,47 @@ +//! Testing topologies + +use anyhow::Result; +use libfalcon::{Runner, node, unit::gb}; + +use crate::{eos::EosNode, frr::FrrNode, mgd::MgdNode}; + +pub struct Trio { + pub d: Runner, + pub ox: MgdNode, + pub cr1: FrrNode, + pub cr2: EosNode, +} + +pub fn trio(name: &str) -> Result { + let mut d = Runner::new(name); + + // nodes + node!(d, ox, "helios-2.9", 4, gb(4)); + node!(d, cr1, "debian-13.2", 4, gb(4)); + node!(d, cr2, "eos-4.35", 4, gb(4)); + + // links + let mut mac_counter = 0; + let mut new_mac = || { + mac_counter += 1; + format!("a8:40:25:00:00:{mac_counter:02}") + }; + + d.softnpu_link(ox, cr1, Some(new_mac()), None); + d.softnpu_link(ox, cr2, Some(new_mac()), None); + + d.default_ext_link(ox)?; + d.default_ext_link(cr1)?; + d.default_ext_link(cr2)?; + + d.mount("cargo-bay", "/opt/cargo-bay", ox)?; + d.mount("cargo-bay", "/opt/cargo-bay", cr1)?; + d.mount("cargo-bay", "/opt/cargo-bay", cr2)?; + + Ok(Trio { + d, + ox: MgdNode(ox), + cr1: FrrNode(cr1), + cr2: EosNode(cr2), + }) +} diff --git a/falcon-lab/src/util.rs b/falcon-lab/src/util.rs new file mode 100644 index 00000000..12bb60c0 --- /dev/null +++ b/falcon-lab/src/util.rs @@ -0,0 +1,24 @@ +#[macro_export] +macro_rules! wait_for_eq { + ($measure:expr, $expect:expr, $period:expr, $count:expr, $desc:expr) => { + for i in 0..$count { + let measured = $measure; + let expected = $expect; + if measured == expected { + break; + } + if i == $count - 1 { + anyhow::bail!( + "{}: expected {:?}, got {:?}", + $desc, + expected, + measured + ); + } + tokio::time::sleep(Duration::from_secs($period)).await; + } + }; + ($measure:expr, $expect:expr, $desc:expr) => { + wait_for_eq!($measure, $expect, 1, 20, $desc); + }; +} diff --git a/mg-admin-client/src/lib.rs b/mg-admin-client/src/lib.rs index 247d6d49..62ceb635 100644 --- a/mg-admin-client/src/lib.rs +++ b/mg-admin-client/src/lib.rs @@ -22,6 +22,7 @@ progenitor::generate_api!( Prefix = rdb_types::Prefix, AddressFamily = rdb_types::AddressFamily, ProtocolFilter = rdb_types::ProtocolFilter, + PeerId = rdb_types::PeerId, Duration = std::time::Duration, } ); @@ -30,7 +31,6 @@ use colored::*; use rdb_types::{AddressFamily, Prefix, ProtocolFilter}; use std::collections::BTreeMap; use std::io::{Write, stdout}; -use std::net::Ipv4Addr; use tabwriter::TabWriter; use types::{Path, Rib}; @@ -117,8 +117,17 @@ fn print_static_routes(routes: &BTreeMap>, title: &str) { for (prefix, paths) in routes.iter() { write!(&mut tw, "{prefix}").unwrap(); for path in paths.iter() { - writeln!(&mut tw, "\t{}\t{:?}", path.nexthop, path.rib_priority,) - .unwrap(); + let nexthop_display = match &path.nexthop_interface { + Some(iface) => format!("{}({})", iface, path.nexthop), + None => path.nexthop.to_string(), + }; + writeln!( + &mut tw, + "\t{}\t{:?}", + nexthop_display, + path.rib_priority, + ) + .unwrap(); } } @@ -137,7 +146,7 @@ fn print_bgp_routes(routes: &BTreeMap>, title: &str) { "RIB Priority".dimmed(), "Local Pref".dimmed(), "Origin AS".dimmed(), - "Peer ID".dimmed(), + "Peer".dimmed(), "MED".dimmed(), "AS Path".dimmed(), "Stale".dimmed(), @@ -148,14 +157,22 @@ fn print_bgp_routes(routes: &BTreeMap>, title: &str) { write!(&mut tw, "{prefix}").unwrap(); for path in paths.iter() { let bgp = path.bgp.as_ref().unwrap(); + let nexthop_display = match &path.nexthop_interface { + Some(iface) => format!("{}({})", iface, path.nexthop), + None => path.nexthop.to_string(), + }; + let peer_str = match &bgp.peer { + rdb_types::PeerId::Ip(ip) => ip.to_string(), + rdb_types::PeerId::Interface(iface) => iface.clone(), + }; writeln!( &mut tw, "\t{}\t{}\t{:?}\t{}\t{}\t{:?}\t{:?}\t{:?}", - path.nexthop, + nexthop_display, path.rib_priority, bgp.local_pref, bgp.origin_as, - Ipv4Addr::from(bgp.id), + peer_str, bgp.med, bgp.as_path, bgp.stale, diff --git a/mg-api/Cargo.toml b/mg-api/Cargo.toml index 89bce6cd..56f18b5d 100644 --- a/mg-api/Cargo.toml +++ b/mg-api/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] bfd = { path = "../bfd" } bgp = { path = "../bgp" } +chrono.workspace = true dropshot.workspace = true dropshot-api-manager-types.workspace = true rdb = { path = "../rdb" } diff --git a/mg-api/src/lib.rs b/mg-api/src/lib.rs index 1f2e2528..9c681b5b 100644 --- a/mg-api/src/lib.rs +++ b/mg-api/src/lib.rs @@ -10,12 +10,13 @@ use std::{ use bfd::BfdPeerState; use bgp::{ + messages::Afi, params::{ ApplyRequest, ApplyRequestV1, CheckerSource, Neighbor, NeighborResetOp, NeighborResetOpV1, NeighborV1, Origin4, Origin6, PeerInfo, PeerInfoV1, - PeerInfoV2, Router, ShaperSource, + PeerInfoV2, Router, ShaperSource, UnnumberedNeighbor, }, - session::{FsmEventRecord, MessageHistory, MessageHistoryV1}, + session::{FsmEventRecord, MessageHistory, MessageHistoryV1, PeerId}, }; use dropshot::{ HttpError, HttpResponseDeleted, HttpResponseOk, @@ -23,7 +24,8 @@ use dropshot::{ }; use dropshot_api_manager_types::api_versions; use rdb::{ - BfdPeerConfig, Path as RdbPath, Prefix, Prefix4, Prefix6, StaticRouteKey, + BfdPeerConfig, Path as RdbPath, PathV1, Prefix, Prefix4, Prefix6, + StaticRouteKey, types::{AddressFamily, ProtocolFilter}, }; use schemars::JsonSchema; @@ -41,6 +43,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (5, UNNUMBERED), (4, MP_BGP), (3, SWITCH_IDENTIFIERS), (2, IPV6_BASIC), @@ -116,6 +119,9 @@ pub trait MgAdminApi { ) -> Result; // V1/V2 API - legacy Neighbor type with combined import/export policies + + // Neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] async fn create_neighbor( rqctx: RequestContext, @@ -125,7 +131,7 @@ pub trait MgAdminApi { #[endpoint { method = GET, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] async fn read_neighbor( rqctx: RequestContext, - request: Query, + request: Query, ) -> Result, HttpError>; #[endpoint { method = GET, path = "/bgp/config/neighbors", versions = ..VERSION_MP_BGP }] @@ -143,38 +149,70 @@ pub trait MgAdminApi { #[endpoint { method = DELETE, path = "/bgp/config/neighbor", versions = ..VERSION_MP_BGP }] async fn delete_neighbor( rqctx: RequestContext, - request: Query, + request: Query, ) -> Result; - // V3 API - new Neighbor type with explicit per-AF configuration - #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + // V3 API - new Neighbor type with explicit per-AF configuration (numbered peers only) + #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn create_neighbor_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; - #[endpoint { method = GET, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + #[endpoint { method = GET, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn read_neighbor_v2( rqctx: RequestContext, - request: Query, + request: Query, ) -> Result, HttpError>; - #[endpoint { method = GET, path = "/bgp/config/neighbors", versions = VERSION_MP_BGP.. }] + #[endpoint { method = GET, path = "/bgp/config/neighbors", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn read_neighbors_v2( rqctx: RequestContext, request: Query, ) -> Result>, HttpError>; - #[endpoint { method = POST, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + #[endpoint { method = POST, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn update_neighbor_v2( rqctx: RequestContext, request: TypedBody, ) -> Result; - #[endpoint { method = DELETE, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP.. }] + #[endpoint { method = DELETE, path = "/bgp/config/neighbor", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn delete_neighbor_v2( rqctx: RequestContext, - request: Query, + request: Query, + ) -> Result; + + // Unified API (VERSION_UNNUMBERED..) - supports both numbered and unnumbered neighbors + // Uses PeerId in path parameters with FromStr for type-safe parsing + #[endpoint { method = PUT, path = "/bgp/config/neighbor", versions = VERSION_UNNUMBERED.. }] + async fn create_neighbor_v3( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { method = GET, path = "/bgp/config/neighbor/{asn}/{peer}", versions = VERSION_UNNUMBERED.. }] + async fn read_neighbor_v3( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError>; + + #[endpoint { method = GET, path = "/bgp/config/neighbors/{asn}", versions = VERSION_UNNUMBERED.. }] + async fn read_neighbors_v3( + rqctx: RequestContext, + path: Path, + ) -> Result>, HttpError>; + + #[endpoint { method = POST, path = "/bgp/config/neighbor", versions = VERSION_UNNUMBERED.. }] + async fn update_neighbor_v3( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { method = DELETE, path = "/bgp/config/neighbor/{asn}/{peer}", versions = VERSION_UNNUMBERED.. }] + async fn delete_neighbor_v3( + rqctx: RequestContext, + path: Path, ) -> Result; // V1/V2 API clear neighbor (backwards compatibility w/ IPv4 only support) @@ -191,6 +229,70 @@ pub trait MgAdminApi { request: TypedBody, ) -> Result; + // Unnumbered neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + #[endpoint { + method = GET, + path = "/bgp/config/unnumbered-neighbors", + versions = VERSION_UNNUMBERED.., + }] + async fn read_unnumbered_neighbors( + rqctx: RequestContext, + request: Query, + ) -> Result>, HttpError>; + + #[endpoint { + method = PUT, + path = "/bgp/config/unnumbered-neighbor", + versions = VERSION_UNNUMBERED.., + }] + async fn create_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { + method = GET, + path = "/bgp/config/unnumbered-neighbor", + versions = VERSION_UNNUMBERED.., + }] + async fn read_unnumbered_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result, HttpError>; + + #[endpoint { + method = POST, + path = "/bgp/config/unnumbered-neighbor", + versions = VERSION_UNNUMBERED.., + }] + async fn update_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + #[endpoint { + method = DELETE, + path = "/bgp/config/unnumbered-neighbor", + versions = VERSION_UNNUMBERED.., + }] + async fn delete_unnumbered_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result; + + #[endpoint { + method = POST, + path = "/bgp/clear/unnumbered-neighbor", + versions = VERSION_UNNUMBERED.., + }] + async fn clear_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result; + + // IPv4 origin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + #[endpoint { method = PUT, path = "/bgp/config/origin4" }] async fn create_origin4( rqctx: RequestContext, @@ -239,38 +341,60 @@ pub trait MgAdminApi { request: Query, ) -> Result; - #[endpoint { method = GET, path = "/bgp/status/exported" }] + // Old exported endpoint - IPv4 only, no filtering + #[endpoint { method = GET, path = "/bgp/status/exported", versions = ..VERSION_UNNUMBERED }] async fn get_exported( rqctx: RequestContext, request: TypedBody, ) -> Result>>, HttpError>; + // Supports IPv4/IPv6, filtering by peer/AFI, and unnumbered peers + #[endpoint { method = GET, path = "/bgp/status/exported", versions = VERSION_UNNUMBERED.. }] + async fn get_exported_v2( + rqctx: RequestContext, + request: TypedBody, + ) -> Result>>, HttpError>; + // imported moved under /rib/status in VERSION_IPV6_BASIC #[endpoint { method = GET, path = "/bgp/status/imported", versions = ..VERSION_IPV6_BASIC }] async fn get_imported( rqctx: RequestContext, request: TypedBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; // exported moved under /rib/status in VERSION_IPV6_BASIC #[endpoint { method = GET, path = "/bgp/status/selected", versions = ..VERSION_IPV6_BASIC }] async fn get_selected( rqctx: RequestContext, request: TypedBody, - ) -> Result, HttpError>; + ) -> Result, HttpError>; - // imported moved under /rib/status in VERSION_IPV6_BASIC - #[endpoint { method = GET, path = "/rib/status/imported", versions = VERSION_IPV6_BASIC.. }] + // Original version (VERSION_IPV6_BASIC..VERSION_UNNUMBERED): BgpPathProperties.peer is IpAddr + #[endpoint { method = GET, path = "/rib/status/imported", versions = VERSION_IPV6_BASIC..VERSION_UNNUMBERED }] async fn get_rib_imported( rqctx: RequestContext, request: Query, - ) -> Result, HttpError>; + ) -> Result, HttpError>; - // exported moved under /rib/status in VERSION_IPV6_BASIC - #[endpoint { method = GET, path = "/rib/status/selected", versions = VERSION_IPV6_BASIC.. }] + // Original version (VERSION_IPV6_BASIC..VERSION_UNNUMBERED): BgpPathProperties.peer is IpAddr + #[endpoint { method = GET, path = "/rib/status/selected", versions = VERSION_IPV6_BASIC..VERSION_UNNUMBERED }] async fn get_rib_selected( rqctx: RequestContext, request: Query, + ) -> Result, HttpError>; + + // VERSION_UNNUMBERED+: BgpPathProperties.peer is PeerId enum + #[endpoint { method = GET, path = "/rib/status/imported", versions = VERSION_UNNUMBERED.. }] + async fn get_rib_imported_v2( + rqctx: RequestContext, + request: Query, + ) -> Result, HttpError>; + + // VERSION_UNNUMBERED+: BgpPathProperties.peer is PeerId enum + #[endpoint { method = GET, path = "/rib/status/selected", versions = VERSION_UNNUMBERED.. }] + async fn get_rib_selected_v2( + rqctx: RequestContext, + request: Query, ) -> Result, HttpError>; #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = ..VERSION_IPV6_BASIC }] @@ -285,12 +409,18 @@ pub trait MgAdminApi { request: Query, ) -> Result>, HttpError>; - #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_MP_BGP.. }] + #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_MP_BGP..VERSION_UNNUMBERED }] async fn get_neighbors_v3( rqctx: RequestContext, request: Query, ) -> Result>, HttpError>; + #[endpoint { method = GET, path = "/bgp/status/neighbors", versions = VERSION_UNNUMBERED.. }] + async fn get_neighbors_v4( + rqctx: RequestContext, + request: Query, + ) -> Result>, HttpError>; + // V1/V2 API - ApplyRequestV1 with combined import/export policies #[endpoint { method = POST, path = "/bgp/omicron/apply", versions = ..VERSION_MP_BGP }] async fn bgp_apply( @@ -311,14 +441,26 @@ pub trait MgAdminApi { request: TypedBody, ) -> Result, HttpError>; - #[endpoint { method = GET, path = "/bgp/history/message", versions = VERSION_IPV6_BASIC.. }] + #[endpoint { method = GET, path = "/bgp/history/message", versions = VERSION_IPV6_BASIC..VERSION_UNNUMBERED }] async fn message_history_v2( + rqctx: RequestContext, + request: TypedBody, + ) -> Result, HttpError>; + + #[endpoint { method = GET, path = "/bgp/history/message", versions = VERSION_UNNUMBERED.. }] + async fn message_history_v3( rqctx: RequestContext, request: TypedBody, ) -> Result, HttpError>; - #[endpoint { method = GET, path = "/bgp/history/fsm", versions = VERSION_IPV6_BASIC.. }] + #[endpoint { method = GET, path = "/bgp/history/fsm", versions = VERSION_IPV6_BASIC..VERSION_UNNUMBERED }] async fn fsm_history( + rqctx: RequestContext, + request: TypedBody, + ) -> Result, HttpError>; + + #[endpoint { method = GET, path = "/bgp/history/fsm", versions = VERSION_UNNUMBERED.. }] + async fn fsm_history_v2( rqctx: RequestContext, request: TypedBody, ) -> Result, HttpError>; @@ -433,6 +575,18 @@ pub trait MgAdminApi { async fn switch_identifiers( ctx: RequestContext, ) -> Result, HttpError>; + + #[endpoint { method = GET, path = "/ndp/interfaces", versions = VERSION_UNNUMBERED.. }] + async fn get_ndp_interfaces( + rqctx: RequestContext, + request: Query, + ) -> Result>, HttpError>; + + #[endpoint { method = GET, path = "/ndp/interface", versions = VERSION_UNNUMBERED.. }] + async fn get_ndp_interface_detail( + rqctx: RequestContext, + request: Query, + ) -> Result, HttpError>; } /// Identifiers for a switch. @@ -463,18 +617,80 @@ pub struct AsnSelector { pub asn: u32, } +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct ExportedSelector { + /// ASN of the router to get exported prefixes from. + pub asn: u32, + /// Optional peer filter using PeerId enum + pub peer: Option, + /// Optional address family filter (None = all negotiated families) + pub afi: Option, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct DeleteRouterRequest { /// Autonomous system number for the router to remove pub asn: u32, } +// ============================================================================ +// Archived API Types (Pre-VERSION_UNNUMBERED) +// ============================================================================ + +/// V1 API NeighborSelector (numbered peers only) #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] -pub struct NeighborSelector { +#[schemars(rename = "NeighborSelector")] +pub struct NeighborSelectorV1 { pub asn: u32, pub addr: IpAddr, } +// ============================================================================ +// Current API Types (VERSION_UNNUMBERED and later) +// ============================================================================ + +/// V1 Rib with PathV1 +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +#[schemars(rename = "Rib")] +pub struct RibV1(BTreeMap>); + +impl From for RibV1 { + fn from(value: rdb::db::Rib) -> Self { + RibV1( + value + .into_iter() + .map(|(k, v)| { + let paths_v1: BTreeSet = + v.into_iter().map(PathV1::from).collect(); + (k.to_string(), paths_v1) + }) + .collect(), + ) + } +} + +/// Unified neighbor selector supporting both numbered and unnumbered peers +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct NeighborSelector { + pub asn: u32, + /// Peer identifier as a string. + /// + /// - For numbered peers: IP address (e.g., "192.0.2.1" or "2001:db8::1") + /// - For unnumbered peers: Interface name (e.g., "eth0" or "cxgbe0") + /// + /// Server parses as IP address first; if parsing fails, treats as interface name. + /// Uses PeerId::from_str() for type-safe conversion. + pub peer: String, +} + +impl NeighborSelector { + /// Convert peer string to PeerId using FromStr implementation. + /// Tries to parse as IP first, otherwise treats as interface name. + pub fn to_peer_id(&self) -> bgp::session::PeerId { + self.peer.parse().expect("PeerId::from_str never fails") + } +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] #[schemars(rename = "NeighborResetRequest")] pub struct NeighborResetRequestV1 { @@ -483,6 +699,12 @@ pub struct NeighborResetRequestV1 { pub op: NeighborResetOpV1, } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct UnnumberedNeighborSelector { + pub asn: u32, + pub interface: String, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] pub struct NeighborResetRequest { pub asn: u32, @@ -510,6 +732,13 @@ impl From for NeighborResetRequest { } } +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct UnnumberedNeighborResetRequest { + pub asn: u32, + pub interface: String, + pub op: NeighborResetOp, +} + #[derive(Debug, Deserialize, Serialize, JsonSchema)] pub struct DeleteNeighborRequest { pub asn: u32, @@ -535,20 +764,41 @@ pub enum MessageDirection { } #[derive(Debug, Deserialize, JsonSchema, Clone)] -pub struct MessageHistoryRequest { +#[schemars(rename = "MessageHistoryRequest")] +pub struct MessageHistoryRequestV4 { /// ASN of the BGP router pub asn: u32, - /// Optional peer filter - if None, returns history for all peers pub peer: Option, + /// Optional direction filter - if None, returns both sent and received + pub direction: Option, +} + +#[derive(Debug, Serialize, JsonSchema, Clone)] +#[schemars(rename = "MessageHistoryResponse")] +pub struct MessageHistoryResponseV4 { + pub by_peer: HashMap, +} + +/// Unified message history request supporting both numbered and unnumbered peers +#[derive(Debug, Deserialize, JsonSchema, Clone)] +pub struct MessageHistoryRequest { + /// ASN of the BGP router + pub asn: u32, + + /// Optional peer filter using PeerId enum + /// JSON format: {"ip": "192.0.2.1"} or {"interface": "eth0"} + pub peer: Option, /// Optional direction filter - if None, returns both sent and received pub direction: Option, } +/// Unified message history response with string keys from PeerId Display +/// Keys will be "192.0.2.1" or "eth0" format #[derive(Debug, Serialize, JsonSchema, Clone)] pub struct MessageHistoryResponse { - pub by_peer: HashMap, + pub by_peer: HashMap, } #[derive(Debug, Deserialize, Serialize, JsonSchema, Clone, Copy, PartialEq)] @@ -561,22 +811,44 @@ pub enum FsmEventBuffer { } #[derive(Debug, Deserialize, JsonSchema, Clone)] -pub struct FsmHistoryRequest { +#[schemars(rename = "FsmHistoryRequest")] +pub struct FsmHistoryRequestV4 { /// ASN of the BGP router pub asn: u32, - /// Optional peer filter - if None, returns history for all peers pub peer: Option, + /// Which buffer to retrieve - if None, returns major buffer + pub buffer: Option, +} + +#[derive(Debug, Serialize, JsonSchema, Clone)] +#[schemars(rename = "FsmHistoryResponse")] +pub struct FsmHistoryResponseV4 { + /// Events organized by peer address Each peer's value contains only the events from the requested buffer + pub by_peer: HashMap>, +} + +/// Unified FSM history request supporting both numbered and unnumbered peers +#[derive(Debug, Deserialize, JsonSchema, Clone)] +pub struct FsmHistoryRequest { + /// ASN of the BGP router + pub asn: u32, + + /// Optional peer filter using PeerId enum + /// JSON format: {"ip": "192.0.2.1"} or {"interface": "eth0"} + pub peer: Option, /// Which buffer to retrieve - if None, returns major buffer pub buffer: Option, } +/// Unified FSM history response with string keys from PeerId Display +/// Keys will be "192.0.2.1" or "eth0" format #[derive(Debug, Serialize, JsonSchema, Clone)] pub struct FsmHistoryResponse { - /// Events organized by peer address + /// Events organized by peer identifier /// Each peer's value contains only the events from the requested buffer - pub by_peer: HashMap>, + pub by_peer: HashMap>, } #[derive(Debug, Deserialize, Serialize, JsonSchema)] @@ -706,3 +978,48 @@ pub fn filter_rib_by_protocol( } } } + +/// Selector for NDP interface queries +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct NdpInterfaceSelector { + /// ASN of the router + pub asn: u32, + /// Interface name + pub interface: String, +} + +/// NDP state for an interface +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct NdpInterface { + /// Interface name (e.g., "qsfp0") + pub interface: String, + /// Local IPv6 link-local address + pub local_address: Ipv6Addr, + /// IPv6 scope ID (interface index) + pub scope_id: u32, + /// Router lifetime advertised by this router (seconds) + pub router_lifetime: u16, + /// Information about discovered peer (if any, including expired) + pub discovered_peer: Option, +} + +/// Information about a discovered NDP peer +#[derive(Debug, Deserialize, Serialize, JsonSchema, Clone)] +pub struct NdpPeer { + /// Peer IPv6 address + pub address: Ipv6Addr, + /// When the peer was first discovered (ISO 8601 timestamp) + pub discovered_at: String, + /// When the most recent Router Advertisement was received (ISO 8601 timestamp) + pub last_advertisement: String, + /// Router lifetime from RA (seconds) + pub router_lifetime: u16, + /// Reachable time from RA (milliseconds) + pub reachable_time: u32, + /// Retransmit timer from RA (milliseconds) + pub retrans_timer: u32, + /// Whether the peer entry has expired + pub expired: bool, + /// Time until expiry (human-readable), or None if already expired + pub time_until_expiry: Option, +} diff --git a/mg-lower/Cargo.toml b/mg-lower/Cargo.toml index 89659eeb..a2ee97f7 100644 --- a/mg-lower/Cargo.toml +++ b/mg-lower/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dev-dependencies] util = { path = "../util" } +uuid.workspace = true [dependencies] ddm-admin-client = { path = "../ddm-admin-client" } diff --git a/mg-lower/src/dendrite.rs b/mg-lower/src/dendrite.rs index 100ece5d..87796f0c 100644 --- a/mg-lower/src/dendrite.rs +++ b/mg-lower/src/dendrite.rs @@ -58,7 +58,7 @@ impl RouteHash { prefix: Prefix, path: Path, ) -> Result { - let (port_id, link_id) = get_port_and_link(sw, path.nexthop)?; + let (port_id, link_id) = get_port_and_link(sw, &path)?; let rh = RouteHash { cidr: match prefix { @@ -249,8 +249,32 @@ where return Err(e.into()); } } + (IpNet::V4(c), IpAddr::V6(tgt_ip)) => { + let target = types::Ipv6Route { + tag, + port_id, + link_id, + tgt_ip, + vlan_id, + }; + + let update = types::Ipv4OverIpv6RouteUpdate { + cidr: c, + target, + replace: false, + }; + if let Err(e) = rt.block_on(async { + dpd.route_ipv4_over_ipv6_add(&update).await + }) { + dpd_log!(log, + error, + "failed to create route {r:?} {e}"; + "error" => format!("{e}") + ); + return Err(e.into()); + } + } _ => { - // XXX: re-evaluate for RFC 8950 (BGP unnumbered) support dpd_log!(log, error, "mismatched address-family for subnet {} and target {}", r.cidr, r.nexthop; @@ -355,6 +379,33 @@ fn test_tfport_parser() { } fn get_port_and_link( + sw: &impl SwitchZone, + path: &Path, +) -> Result<(types::PortId, types::LinkId), Error> { + // If path has interface binding (unnumbered peer), use it directly + if let IpAddr::V6(nh6) = path.nexthop + && nh6.is_unicast_link_local() + && let Some(ref iface) = path.nexthop_interface + { + let (port, link, _vlan) = parse_tfport_name(iface)?; + let port_name = format!("qsfp{port}"); + let port_id = types::Qsfp::try_from(&port_name) + .map(types::PortId::Qsfp) + .map_err(|e| { + Error::Tfport(format!( + "bad port name ifname: {iface} port name: {port_name}: {e}", + )) + })?; + // TODO breakout considerations + let link_id = types::LinkId(link); + return Ok((port_id, link_id)); + } + + // Standard nexthop resolution for numbered peers + resolve_port_and_link(sw, path.nexthop) +} + +fn resolve_port_and_link( sw: &impl SwitchZone, nexthop: IpAddr, ) -> Result<(types::PortId, types::LinkId), Error> { @@ -409,6 +460,10 @@ pub(crate) fn get_routes_for_prefix( let mut result: Vec = Vec::new(); for r in dpd_routes.iter() { + let dpd_client::types::Route::V4(r) = r else { + // TODO v6 nexthop? + continue; + }; if r.tag != MG_LOWER_TAG { continue; } diff --git a/mg-lower/src/platform.rs b/mg-lower/src/platform.rs index 650740c7..41c64767 100644 --- a/mg-lower/src/platform.rs +++ b/mg-lower/src/platform.rs @@ -19,7 +19,7 @@ pub(crate) trait Dpd { &self, cidr: &Ipv4Net, ) -> Result< - dpd_client::ResponseValue>, + dpd_client::ResponseValue>, progenitor_client::Error, >; async fn route_ipv6_get( @@ -79,6 +79,11 @@ pub(crate) trait Dpd { body: &'a Ipv4RouteUpdate, ) -> Result, progenitor_client::Error>; + async fn route_ipv4_over_ipv6_add<'a>( + &'a self, + body: &'a Ipv4OverIpv6RouteUpdate, + ) -> Result, progenitor_client::Error>; + async fn route_ipv6_add<'a>( &'a self, body: &'a Ipv6RouteUpdate, @@ -157,7 +162,7 @@ impl Dpd for ProductionDpd { &self, cidr: &Ipv4Net, ) -> Result< - dpd_client::ResponseValue>, + dpd_client::ResponseValue>, progenitor_client::Error, > { self.client.route_ipv4_get(cidr).await @@ -240,6 +245,14 @@ impl Dpd for ProductionDpd { self.client.route_ipv4_add(body).await } + async fn route_ipv4_over_ipv6_add<'a>( + &'a self, + body: &'a Ipv4OverIpv6RouteUpdate, + ) -> Result, progenitor_client::Error> + { + self.client.route_ipv4_over_ipv6_add(body).await + } + async fn route_ipv6_add<'a>( &'a self, body: &'a Ipv6RouteUpdate, @@ -368,7 +381,7 @@ pub(crate) mod test { /// useful for tests. pub(crate) struct TestDpd { pub(crate) links: Mutex>, - pub(crate) v4_routes: Mutex>>, + pub(crate) v4_routes: Mutex>>, pub(crate) v6_routes: Mutex>>, pub(crate) v4_addrs: HashMap>, pub(crate) v6_addrs: HashMap>, @@ -412,7 +425,7 @@ pub(crate) mod test { &self, cidr: &Ipv4Net, ) -> Result< - dpd_client::ResponseValue>, + dpd_client::ResponseValue>, progenitor_client::Error, > { let result = self @@ -528,10 +541,35 @@ pub(crate) mod test { let mut routes = self.v4_routes.lock().unwrap(); match routes.get_mut(&body.cidr) { Some(targets) => { - targets.push(body.target.clone()); + targets.push(Route::V4(body.target.clone())); } None => { - routes.insert(body.cidr, vec![body.target.clone()]); + routes.insert( + body.cidr, + vec![Route::V4(body.target.clone())], + ); + } + } + Ok(dpd_response_ok!(())) + } + + async fn route_ipv4_over_ipv6_add<'a>( + &'a self, + body: &'a Ipv4OverIpv6RouteUpdate, + ) -> Result< + dpd_client::ResponseValue<()>, + progenitor_client::Error, + > { + let mut routes = self.v4_routes.lock().unwrap(); + match routes.get_mut(&body.cidr) { + Some(targets) => { + targets.push(Route::V6(body.target.clone())); + } + None => { + routes.insert( + body.cidr, + vec![Route::V6(body.target.clone())], + ); } } Ok(dpd_response_ok!(())) @@ -569,9 +607,13 @@ pub(crate) mod test { let mut routes = self.v4_routes.lock().unwrap(); if let Some(targets) = routes.get_mut(cidr) { targets.retain(|x| { - !(x.tgt_ip == *tgt_ip - && x.link_id == *link_id - && x.port_id == *port_id) + if let Route::V4(x) = x { + !(x.tgt_ip == *tgt_ip + && x.link_id == *link_id + && x.port_id == *port_id) + } else { + false + } }); } routes.retain(|_, v| !v.is_empty()); diff --git a/mg-lower/src/test.rs b/mg-lower/src/test.rs index 7ccf6f16..f561625f 100644 --- a/mg-lower/src/test.rs +++ b/mg-lower/src/test.rs @@ -32,6 +32,7 @@ async fn sync_prefix_test() { "4.0.0.0/24".parse::().unwrap().into(), vec![Path { nexthop: "3.0.0.1".parse().unwrap(), + nexthop_interface: None, shutdown: false, rib_priority: 10, bgp: None, @@ -181,33 +182,33 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { // Add three initial prefixes to dpd dpd.v4_routes.lock().unwrap().insert( "1.0.0.0/24".parse().unwrap(), - vec![Ipv4Route { + vec![dpd_client::types::Route::V4(Ipv4Route { link_id: LinkId(0), port_id: PortId::Qsfp("qsfp0".parse().unwrap()), tag: String::from("mg_lower_test"), tgt_ip: "1.0.0.1".parse().unwrap(), vlan_id: None, - }], + })], ); dpd.v4_routes.lock().unwrap().insert( "2.0.0.0/24".parse().unwrap(), - vec![Ipv4Route { + vec![dpd_client::types::Route::V4(Ipv4Route { link_id: LinkId(0), port_id: PortId::Qsfp("qsfp0".parse().unwrap()), tag: String::from("mg_lower_test"), tgt_ip: "2.0.0.1".parse().unwrap(), vlan_id: None, - }], + })], ); dpd.v4_routes.lock().unwrap().insert( "3.0.0.0/24".parse().unwrap(), - vec![Ipv4Route { + vec![dpd_client::types::Route::V4(Ipv4Route { link_id: LinkId(0), port_id: PortId::Qsfp("qsfp1".parse().unwrap()), tag: String::from("mg_lower_test"), tgt_ip: "3.0.0.1".parse().unwrap(), vlan_id: None, - }], + })], ); // Add three initial prefixes to ddm @@ -235,6 +236,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { "1.0.0.0/24".parse::().unwrap().into(), vec![Path { nexthop: "1.0.0.1".parse().unwrap(), + nexthop_interface: None, shutdown: false, rib_priority: 10, bgp: None, @@ -247,6 +249,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { "2.0.0.0/24".parse::().unwrap().into(), vec![Path { nexthop: "2.0.0.1".parse().unwrap(), + nexthop_interface: None, shutdown: false, rib_priority: 10, bgp: None, @@ -259,6 +262,7 @@ fn test_setup(tep: Ipv6Addr, dpd: &TestDpd, ddm: &TestDdm, rib: &mut Rib) { "3.0.0.0/24".parse::().unwrap().into(), vec![Path { nexthop: "3.0.0.1".parse().unwrap(), + nexthop_interface: None, shutdown: false, rib_priority: 10, bgp: None, diff --git a/mgadm/Cargo.toml b/mgadm/Cargo.toml index 7e50fd7f..dfa0bb0c 100644 --- a/mgadm/Cargo.toml +++ b/mgadm/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -bgp = { path = "../bgp", features = ["clap"] } -mg-common = { path = "../mg-common" } -mg-admin-client = { path = "../mg-admin-client" } +bgp = { workspace = true, features = ["clap"] } +mg-common.workspace = true +mg-admin-client.workspace = true clap.workspace = true anyhow.workspace = true oxide-tokio-rt.workspace = true @@ -19,4 +19,5 @@ humantime.workspace = true serde_json.workspace = true oxnet.workspace = true tabwriter.workspace = true -rdb = { version = "0.1.0", path = "../rdb", features = ["clap"] } +rdb.workspace = true +natord.workspace = true diff --git a/mgadm/src/bgp.rs b/mgadm/src/bgp.rs index 1cb7c4e1..f568e61f 100644 --- a/mgadm/src/bgp.rs +++ b/mgadm/src/bgp.rs @@ -5,20 +5,6 @@ use anyhow::Result; use bgp::{messages::Afi, params::JitterRange}; use clap::{Args, Subcommand, ValueEnum}; - -fn jitter_range_to_api(j: JitterRange) -> types::JitterRange { - types::JitterRange { - min: j.min, - max: j.max, - } -} - -fn afi_to_api(afi: Afi) -> types::Afi { - match afi { - Afi::Ipv4 => types::Afi::Ipv4, - Afi::Ipv6 => types::Afi::Ipv6, - } -} use colored::*; use mg_admin_client::{ Client, @@ -27,7 +13,7 @@ use mg_admin_client::{ Ipv6UnicastConfig, NeighborResetRequest, }, }; -use rdb::types::{PolicyAction, Prefix4, Prefix6}; +use rdb::types::{PeerId, Prefix4, Prefix6}; use std::{ fs::read_to_string, io::{Write, stdout}, @@ -36,6 +22,29 @@ use std::{ }; use tabwriter::TabWriter; +fn jitter_range_to_api(j: JitterRange) -> types::JitterRange { + types::JitterRange { + min: j.min, + max: j.max, + } +} + +fn afi_to_api(afi: Afi) -> types::Afi { + match afi { + Afi::Ipv4 => types::Afi::Ipv4, + Afi::Ipv6 => types::Afi::Ipv6, + } +} + +fn peer_id_to_api(peer_id: bgp::session::PeerId) -> PeerId { + match peer_id { + bgp::session::PeerId::Ip(ip) => PeerId::Ip(ip), + bgp::session::PeerId::Interface(interface) => { + PeerId::Interface(interface) + } + } +} + #[derive(Subcommand, Debug)] pub enum Commands { /// Manage router configuration. @@ -106,6 +115,14 @@ pub enum StatusCmd { Exported { #[clap(env)] asn: u32, + + /// Optional peer filter (IP address or interface name) + #[clap(long)] + peer: Option, + + /// Optional address family filter (ipv4 or ipv6). If not specified, shows both. + #[clap(long, value_enum)] + afi: Option, }, } @@ -119,8 +136,8 @@ pub struct HistorySubcommand { pub enum HistoryCmd { /// Get FSM event history for BGP sessions. Fsm { - /// Optional: Filter by specific peer address. - peer: Option, + /// Optional: Filter by specific peer (IP address for numbered, interface name for unnumbered). + peer: Option, /// Which buffer to display: 'major' (default) or 'all'. #[clap(default_value = "major")] @@ -141,8 +158,8 @@ pub enum HistoryCmd { /// Get BGP message history for sessions. Message { - /// Peer address to show history for. - peer: IpAddr, + /// Peer to show history for (IP address for numbered, interface name for unnumbered). + peer: String, /// BGP Autonomous System number. Can be specified via ASN env var. #[clap(env)] @@ -173,8 +190,8 @@ pub struct ClearSubcommand { pub enum ClearCmd { /// Clear the state of the selected BGP neighbor. Neighbor { - /// IP address of the neighbor you want to clear the state of. - addr: IpAddr, + /// Peer identifier (IP address for numbered, interface name for unnumbered). + peer: String, /// BGP Autonomous System number. Can be a 16-bit or 32-bit unsigned value. #[clap(env)] @@ -295,9 +312,10 @@ pub enum NeighborCmd { /// Create a neighbor configuration. Create(Neighbor), - /// Read a neighbor configuration. + /// Read a neighbor configuration (supports both IP and interface). Read { - addr: IpAddr, + /// Peer identifier (IP address for numbered, interface name for unnumbered) + peer: String, #[clap(env)] asn: u32, }, @@ -305,9 +323,10 @@ pub enum NeighborCmd { /// Update a neighbor's configuration. Update(Neighbor), - /// Delete a neighbor configuration + /// Delete a neighbor configuration (supports both IP and interface) Delete { - addr: IpAddr, + /// Peer identifier (IP address for numbered, interface name for unnumbered) + peer: String, #[clap(env)] asn: u32, }, @@ -481,25 +500,6 @@ pub struct RouterConfig { pub asn: u32, } -#[derive(Args, Debug)] -pub struct ExportPolicy { - /// Address of the peer to apply this policy to. - pub addr: IpAddr, - - /// Prefix this policy applies to - pub prefix: Prefix4, - - /// Priority of the policy, higher value is higher priority. - pub priority: u16, - - /// The policy action to apply. - pub action: PolicyAction, - - /// Autonomous system number for the router to add the export policy to. - #[clap(env)] - pub asn: u32, -} - #[derive(Args, Debug)] pub struct Originate4 { /// Autonomous system number for the router to originated the prefixes from. @@ -535,12 +535,21 @@ pub struct Neighbor { /// Name for this neighbor name: String, - /// Neighbor address - addr: IpAddr, + /// Peer identifier: either an IP address (numbered) or interface name (unnumbered) + peer: String, /// Peer group to add the neighbor to. group: String, + /// Autonomous system number for the router to add the neighbor to. + #[clap(env)] + asn: u32, + + /// Act as a default IPv6 router for unnumbered peering (some routers like Arista require this). + /// Ignored for numbered peers. Maps to router lifetime of 1800s if true, 0s if false. + #[arg(long, default_value_t = false)] + act_as_default_router: bool, + /// Neighbor BGP TCP port. #[arg(long, default_value_t = 179)] port: u16, @@ -648,30 +657,58 @@ pub struct Neighbor { /// IPv6 nexthop override for this neighbor (requires --enable-ipv6). #[arg(long, requires = "enable_ipv6")] pub nexthop6: Option, +} - /// Autonomous system number for the router to add the neighbor to. - #[clap(env)] - pub asn: u32, +/// Peer type determined by parsing the peer string +enum PeerType { + Numbered(IpAddr), + Unnumbered(String), +} + +/// API neighbor type wrapper for conversion +enum ApiNeighborType { + Numbered(types::Neighbor), + Unnumbered(types::UnnumberedNeighbor), } -impl From for types::Neighbor { - fn from(n: Neighbor) -> types::Neighbor { +/// Determine if a peer string is an IP address or interface name +fn parse_peer_type(peer: &str) -> PeerType { + match peer.parse::() { + Ok(addr) => PeerType::Numbered(addr), + Err(_) => PeerType::Unnumbered(peer.to_string()), + } +} + +impl Neighbor { + /// Convert to either numbered or unnumbered neighbor type based on peer detection + fn into_api_types(self) -> Result { + match parse_peer_type(&self.peer) { + PeerType::Numbered(addr) => { + Ok(ApiNeighborType::Numbered(self.into_numbered(addr))) + } + PeerType::Unnumbered(interface) => { + Ok(ApiNeighborType::Unnumbered(self.into_unnumbered(interface))) + } + } + } + + fn into_numbered(self, addr: IpAddr) -> types::Neighbor { // Build IPv4 unicast config if enabled - let ipv4_unicast = if n.enable_ipv4 { - let import_policy = match n.allow_import4 { + let ipv4_unicast = if self.enable_ipv4 { + let import_policy = match self.allow_import4.clone() { Some(prefixes) => { ImportExportPolicy4::Allow(prefixes.into_iter().collect()) } None => ImportExportPolicy4::NoFiltering, }; - let export_policy = match n.allow_export4 { + let export_policy = match self.allow_export4.clone() { Some(prefixes) => { ImportExportPolicy4::Allow(prefixes.into_iter().collect()) } None => ImportExportPolicy4::NoFiltering, }; Some(Ipv4UnicastConfig { - nexthop: n.nexthop4, + nexthop: self.nexthop4, import_policy, export_policy, }) @@ -680,21 +717,21 @@ impl From for types::Neighbor { }; // Build IPv6 unicast config if enabled - let ipv6_unicast = if n.enable_ipv6 { - let import_policy = match n.allow_import6 { + let ipv6_unicast = if self.enable_ipv6 { + let import_policy = match self.allow_import6.clone() { Some(prefixes) => { ImportExportPolicy6::Allow(prefixes.into_iter().collect()) } None => ImportExportPolicy6::NoFiltering, }; - let export_policy = match n.allow_export6 { + let export_policy = match self.allow_export6.clone() { Some(prefixes) => { ImportExportPolicy6::Allow(prefixes.into_iter().collect()) } None => ImportExportPolicy6::NoFiltering, }; Some(Ipv6UnicastConfig { - nexthop: n.nexthop6, + nexthop: self.nexthop6, import_policy, export_policy, }) @@ -703,32 +740,115 @@ impl From for types::Neighbor { }; types::Neighbor { - asn: n.asn, - remote_asn: n.remote_asn, - min_ttl: n.min_ttl, - name: n.name, - host: SocketAddr::new(n.addr, n.port).to_string(), - hold_time: n.hold_time, - idle_hold_time: n.idle_hold_time, - connect_retry: n.connect_retry_time, - keepalive: n.keepalive_time, - delay_open: n.delay_open_time, - resolution: n.clock_resolution, - group: n.group, - passive: n.passive_connection, - md5_auth_key: n.md5_auth_key.clone(), - multi_exit_discriminator: n.med, - communities: n.communities, - local_pref: n.local_pref, - enforce_first_as: n.enforce_first_as, + asn: self.asn, + remote_asn: self.remote_asn, + min_ttl: self.min_ttl, + name: self.name.clone(), + host: SocketAddr::new(addr, self.port).to_string(), + hold_time: self.hold_time, + idle_hold_time: self.idle_hold_time, + connect_retry: self.connect_retry_time, + keepalive: self.keepalive_time, + delay_open: self.delay_open_time, + resolution: self.clock_resolution, + group: self.group.clone(), + passive: self.passive_connection, + md5_auth_key: self.md5_auth_key.clone(), + multi_exit_discriminator: self.med, + communities: self.communities.clone(), + local_pref: self.local_pref, + enforce_first_as: self.enforce_first_as, + ipv4_unicast, + ipv6_unicast, + vlan_id: self.vlan_id, + connect_retry_jitter: self + .connect_retry_jitter + .map(jitter_range_to_api), + idle_hold_jitter: self.idle_hold_jitter.map(jitter_range_to_api), + deterministic_collision_resolution: self + .deterministic_collision_resolution, + } + } + + fn into_unnumbered(self, interface: String) -> types::UnnumberedNeighbor { + // Build IPv4 unicast config if enabled + let ipv4_unicast = if self.enable_ipv4 { + let import_policy = match self.allow_import4.clone() { + Some(prefixes) => { + ImportExportPolicy4::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy4::NoFiltering, + }; + let export_policy = match self.allow_export4.clone() { + Some(prefixes) => { + ImportExportPolicy4::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy4::NoFiltering, + }; + Some(Ipv4UnicastConfig { + nexthop: self.nexthop4, + import_policy, + export_policy, + }) + } else { + None + }; + + // Build IPv6 unicast config if enabled + let ipv6_unicast = if self.enable_ipv6 { + let import_policy = match self.allow_import6.clone() { + Some(prefixes) => { + ImportExportPolicy6::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy6::NoFiltering, + }; + let export_policy = match self.allow_export6.clone() { + Some(prefixes) => { + ImportExportPolicy6::Allow(prefixes.into_iter().collect()) + } + None => ImportExportPolicy6::NoFiltering, + }; + Some(Ipv6UnicastConfig { + nexthop: self.nexthop6, + import_policy, + export_policy, + }) + } else { + None + }; + + types::UnnumberedNeighbor { + asn: self.asn, + remote_asn: self.remote_asn, + min_ttl: self.min_ttl, + act_as_a_default_ipv6_router: if self.act_as_default_router { + 1800 + } else { + 0 + }, + name: self.name.clone(), + interface, + hold_time: self.hold_time, + idle_hold_time: self.idle_hold_time, + connect_retry: self.connect_retry_time, + keepalive: self.keepalive_time, + delay_open: self.delay_open_time, + resolution: self.clock_resolution, + group: self.group.clone(), + passive: self.passive_connection, + md5_auth_key: self.md5_auth_key.clone(), + multi_exit_discriminator: self.med, + communities: self.communities.clone(), + local_pref: self.local_pref, + enforce_first_as: self.enforce_first_as, ipv4_unicast, ipv6_unicast, - vlan_id: n.vlan_id, - connect_retry_jitter: n + vlan_id: self.vlan_id, + connect_retry_jitter: self .connect_retry_jitter .map(jitter_range_to_api), - idle_hold_jitter: n.idle_hold_jitter.map(jitter_range_to_api), - deterministic_collision_resolution: n + idle_hold_jitter: self.idle_hold_jitter.map(jitter_range_to_api), + deterministic_collision_resolution: self .deterministic_collision_resolution, } } @@ -748,12 +868,12 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { ConfigCmd::Neighbor(cmd) => match cmd.command { NeighborCmd::List { asn } => list_nbr(asn, c).await?, NeighborCmd::Create(nbr) => create_nbr(nbr, c).await?, - NeighborCmd::Read { asn, addr } => { - read_nbr(asn, addr, c).await? + NeighborCmd::Read { asn, peer } => { + read_nbr(asn, peer, c).await? } NeighborCmd::Update(nbr) => update_nbr(nbr, c).await?, - NeighborCmd::Delete { asn, addr } => { - delete_nbr(asn, addr, c).await? + NeighborCmd::Delete { asn, peer } => { + delete_nbr(asn, peer, c).await? } }, @@ -812,7 +932,9 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { StatusCmd::Neighbors { asn, mode } => { get_neighbors(c, asn, mode).await? } - StatusCmd::Exported { asn } => get_exported(c, asn).await?, + StatusCmd::Exported { asn, peer, afi } => { + get_exported(c, asn, peer, afi).await? + } }, Commands::History(cmd) => match cmd.command { @@ -846,9 +968,9 @@ pub async fn commands(command: Commands, c: Client) -> Result<()> { Commands::Clear(cmd) => match cmd.command { ClearCmd::Neighbor { asn, - addr, + peer, operation, - } => clear_nbr(asn, addr, operation, c).await?, + } => clear_nbr(asn, peer, operation, c).await?, }, Commands::Omicron(cmd) => match cmd.command { @@ -902,9 +1024,18 @@ async fn get_neighbors( asn: u32, mode: NeighborDisplayMode, ) -> Result<()> { - let result = c.get_neighbors_v3(asn).await?; + let result = c.get_neighbors_v4(asn).await?.into_inner(); let mut sorted: Vec<_> = result.iter().collect(); - sorted.sort_by_key(|(ip, _)| ip.parse::().ok()); + + // Sort using natural sorting to handle both IP addresses and interface names + sorted.sort_by(|(a, _), (b, _)| { + // Try parsing as IP addresses first for proper IP sorting + match (a.parse::(), b.parse::()) { + (Ok(ip_a), Ok(ip_b)) => ip_a.cmp(&ip_b), + // Fall back to natural sorting for interface names or mixed cases + _ => natord::compare(a, b), + } + }); match mode { NeighborDisplayMode::summary => { @@ -1138,9 +1269,25 @@ fn display_neighbors_detail( Ok(()) } -async fn get_exported(c: Client, asn: u32) -> Result<()> { +async fn get_exported( + c: Client, + asn: u32, + peer: Option, + afi: Option, +) -> Result<()> { + // Parse peer filter if provided and convert to API type + let peer_id = peer.map(|p| { + let bgp_peer_id: bgp::session::PeerId = + p.parse().expect("PeerId::from_str should always succeed"); + peer_id_to_api(bgp_peer_id) + }); + let exported = c - .get_exported(&types::AsnSelector { asn }) + .get_exported_v2(&types::ExportedSelector { + asn, + peer: peer_id, + afi: afi.map(afi_to_api), + }) .await? .into_inner(); @@ -1149,44 +1296,130 @@ async fn get_exported(c: Client, asn: u32) -> Result<()> { } async fn list_nbr(asn: u32, c: Client) -> Result<()> { - let nbrs = c.read_neighbors_v2(asn).await?; - println!("{nbrs:#?}"); + // Get both numbered and unnumbered neighbors + let numbered = c.read_neighbors_v3(asn).await?.into_inner(); + let unnumbered = c.read_unnumbered_neighbors(asn).await?.into_inner(); + + if numbered.is_empty() && unnumbered.is_empty() { + println!("No neighbors configured for ASN {}", asn); + return Ok(()); + } + + // Use TabWriter for formatted output + let mut tw = TabWriter::new(stdout()); + writeln!( + &mut tw, + "{}\t{}\t{}\t{}", + "Peer".dimmed(), + "ASN".dimmed(), + "Group".dimmed(), + "Name".dimmed(), + )?; + + // Display numbered neighbors + for nbr in &numbered { + writeln!( + &mut tw, + "{}\t{}\t{}\t{}", + nbr.host, nbr.asn, nbr.group, nbr.name, + )?; + } + + // Display unnumbered neighbors + for nbr in &unnumbered { + writeln!( + &mut tw, + "{}\t{}\t{}\t{}", + nbr.interface, nbr.asn, nbr.group, nbr.name, + )?; + } + + tw.flush()?; Ok(()) } async fn create_nbr(nbr: Neighbor, c: Client) -> Result<()> { - c.create_neighbor_v2(&nbr.into()).await?; + match nbr.into_api_types()? { + ApiNeighborType::Numbered(n) => { + c.create_neighbor_v3(&n).await?; + } + ApiNeighborType::Unnumbered(n) => { + c.create_unnumbered_neighbor(&n).await?; + } + } Ok(()) } -async fn read_nbr(asn: u32, addr: IpAddr, c: Client) -> Result<()> { - let nbr = c.read_neighbor_v2(&addr, asn).await?.into_inner(); - println!("{nbr:#?}"); +async fn read_nbr(asn: u32, peer: String, c: Client) -> Result<()> { + match parse_peer_type(&peer) { + PeerType::Numbered(addr) => { + let nbr = c + .read_neighbor_v3(asn, &addr.to_string()) + .await? + .into_inner(); + println!("{nbr:#?}"); + } + PeerType::Unnumbered(interface) => { + let nbr = c + .read_unnumbered_neighbor(asn, &interface) + .await? + .into_inner(); + println!("{nbr:#?}"); + } + } Ok(()) } async fn update_nbr(nbr: Neighbor, c: Client) -> Result<()> { - c.update_neighbor_v2(&nbr.into()).await?; + match nbr.into_api_types()? { + ApiNeighborType::Numbered(n) => { + c.update_neighbor_v3(&n).await?; + } + ApiNeighborType::Unnumbered(n) => { + c.update_unnumbered_neighbor(&n).await?; + } + } Ok(()) } -async fn delete_nbr(asn: u32, addr: IpAddr, c: Client) -> Result<()> { - c.delete_neighbor_v2(&addr, asn).await?; +async fn delete_nbr(asn: u32, peer: String, c: Client) -> Result<()> { + match parse_peer_type(&peer) { + PeerType::Numbered(addr) => { + c.delete_neighbor_v3(asn, &addr.to_string()).await?; + } + PeerType::Unnumbered(interface) => { + c.delete_unnumbered_neighbor(asn, &interface).await?; + } + } Ok(()) } async fn clear_nbr( asn: u32, - addr: IpAddr, + peer: String, operation: NeighborOperation, c: Client, ) -> Result<()> { - c.clear_neighbor_v2(&NeighborResetRequest { - asn, - addr, - op: operation.into(), - }) - .await?; + match parse_peer_type(&peer) { + PeerType::Numbered(addr) => { + c.clear_neighbor_v2(&NeighborResetRequest { + asn, + addr, + op: operation.into(), + }) + .await?; + } + PeerType::Unnumbered(interface) => { + c.clear_unnumbered_neighbor( + &types::UnnumberedNeighborResetRequest { + asn, + interface, + op: operation.into(), + }, + ) + .await?; + } + } Ok(()) } @@ -1342,7 +1575,7 @@ async fn delete_shp(asn: u32, c: Client) -> Result<()> { async fn get_fsm_history( c: Client, asn: u32, - peer: Option, + peer: Option, buffer: &str, limit_str: &str, wide: bool, @@ -1358,18 +1591,25 @@ async fn get_fsm_history( _ => "Major Events", }; + // Parse peer filter if provided and convert to API type + let peer_id = peer.as_ref().map(|p| { + let bgp_peer_id: bgp::session::PeerId = + p.parse().expect("PeerId::from_str should always succeed"); + peer_id_to_api(bgp_peer_id) + }); + let result = c - .fsm_history(&types::FsmHistoryRequest { + .fsm_history_v2(&types::FsmHistoryRequest { asn, - peer, + peer: peer_id, buffer: buffer_type, }) .await? .into_inner(); if result.by_peer.is_empty() { - if let Some(peer_addr) = peer { - println!("No FSM history found for peer {}", peer_addr); + if let Some(peer_str) = peer { + println!("No FSM history found for peer {}", peer_str); } else { println!("No FSM history found for ASN {}", asn); } @@ -1472,7 +1712,7 @@ async fn get_fsm_history( async fn get_message_history( c: Client, asn: u32, - peer: IpAddr, + peer: String, direction: &str, limit_str: &str, wide: bool, @@ -1495,10 +1735,16 @@ async fn get_message_history( } }; + // Parse peer and convert to API type + let bgp_peer_id: bgp::session::PeerId = peer + .parse() + .expect("PeerId::from_str should always succeed"); + let peer_id = peer_id_to_api(bgp_peer_id); + let result = c - .message_history_v2(&types::MessageHistoryRequest { + .message_history_v3(&types::MessageHistoryRequest { asn, - peer: Some(peer), + peer: Some(peer_id), direction: dir, }) .await? @@ -1639,4 +1885,51 @@ mod tests { assert_eq!(originate6.prefixes[0].value.to_string(), "2001:db8::"); assert_eq!(originate6.prefixes[0].length, 32); } + + #[test] + fn test_parse_peer_type_ipv4() { + // Test that IPv4 addresses are detected as numbered peers + match parse_peer_type("192.168.1.1") { + PeerType::Numbered(addr) => { + assert_eq!(addr.to_string(), "192.168.1.1"); + } + PeerType::Unnumbered(_) => panic!("Expected numbered peer"), + } + } + + #[test] + fn test_parse_peer_type_ipv6() { + // Test that IPv6 addresses are detected as numbered peers + match parse_peer_type("2001:db8::1") { + PeerType::Numbered(addr) => { + assert_eq!(addr.to_string(), "2001:db8::1"); + } + PeerType::Unnumbered(_) => panic!("Expected numbered peer"), + } + + match parse_peer_type("3fff:10::2") { + PeerType::Numbered(addr) => { + assert_eq!(addr.to_string(), "3fff:10::2"); + } + PeerType::Unnumbered(_) => panic!("Expected numbered peer"), + } + } + + #[test] + fn test_parse_peer_type_interface() { + // Test that interface names are detected as unnumbered peers + match parse_peer_type("eth0") { + PeerType::Unnumbered(iface) => { + assert_eq!(iface, "eth0"); + } + PeerType::Numbered(_) => panic!("Expected unnumbered peer"), + } + + match parse_peer_type("qsfp0") { + PeerType::Unnumbered(iface) => { + assert_eq!(iface, "qsfp0"); + } + PeerType::Numbered(_) => panic!("Expected unnumbered peer"), + } + } } diff --git a/mgadm/src/main.rs b/mgadm/src/main.rs index 9229dbb7..3ceec95c 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 ndp; mod rib; mod static_routing; @@ -55,6 +56,10 @@ enum Commands { /// RIB configuration commands. #[command(subcommand)] Rib(rib::Commands), + + /// Neighbor Discovery Protocol state for BGP unnumbered + #[command(subcommand)] + Ndp(ndp::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::Ndp(command) => ndp::commands(command, client).await?, } Ok(()) } diff --git a/mgadm/src/ndp.rs b/mgadm/src/ndp.rs new file mode 100644 index 00000000..663528a8 --- /dev/null +++ b/mgadm/src/ndp.rs @@ -0,0 +1,137 @@ +// 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 anyhow::Result; +use clap::Subcommand; +use colored::Colorize; +use mg_admin_client::Client; +use std::io::{Write, stdout}; +use tabwriter::TabWriter; + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// List all interfaces with NDP discovery state + List { + #[clap(env)] + asn: u32, + }, + + /// Get detailed NDP state for a specific interface + Status { + #[clap(env)] + asn: u32, + interface: String, + }, +} + +pub async fn commands(command: Commands, c: Client) -> Result<()> { + match command { + Commands::List { asn } => ndp_list(asn, c).await?, + Commands::Status { asn, interface } => { + ndp_status(asn, interface, c).await? + } + } + Ok(()) +} + +async fn ndp_list(asn: u32, c: Client) -> Result<()> { + let interfaces = c.get_ndp_interfaces(asn).await?.into_inner(); + + if interfaces.is_empty() { + println!("No NDP-managed interfaces found for ASN {}", asn); + return Ok(()); + } + + let mut tw = TabWriter::new(stdout()); + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}", + "Interface".dimmed(), + "Local Address".dimmed(), + "Scope ID".dimmed(), + "Discovered Peer".dimmed(), + "Reachable".dimmed(), + )?; + + for iface in interfaces { + let (peer_str, reachable_str) = match &iface.discovered_peer { + Some(peer) => { + let addr_str = format!("{}%{}", peer.address, iface.interface); + let reachable = if peer.expired { + "No (expired)".red() + } else { + "Yes".green() + }; + (addr_str, reachable) + } + None => ("None".to_string(), "N/A".dimmed()), + }; + + writeln!( + &mut tw, + "{}\t{}\t{}\t{}\t{}", + iface.interface, + iface.local_address, + iface.scope_id, + peer_str, + reachable_str, + )?; + } + + tw.flush()?; + Ok(()) +} + +async fn ndp_status(asn: u32, interface: String, c: Client) -> Result<()> { + let detail = c + .get_ndp_interface_detail(asn, &interface) + .await? + .into_inner(); + + println!("{}", "=".repeat(80)); + println!("NDP State: {}", interface); + println!("{}", "=".repeat(80)); + println!(); + + println!("Interface Information:"); + println!(" Name: {}", detail.interface); + println!(" Local Address: {}", detail.local_address); + println!(" Scope ID: {}", detail.scope_id); + println!( + " Router Lifetime (advertised): {}s", + detail.router_lifetime + ); + println!(); + + if let Some(peer) = detail.discovered_peer { + if peer.expired { + println!("{}", "Discovered Peer (EXPIRED):".red()); + } else { + println!("Discovered Peer:"); + } + + println!(" Address: {}", peer.address); + println!(" Discovered At: {}", peer.discovered_at); + println!(" Last Advertisement: {}", peer.last_advertisement); + println!(" Router Lifetime: {}s", peer.router_lifetime); + println!(" Reachable Time: {}ms", peer.reachable_time); + println!(" Retrans Timer: {}ms", peer.retrans_timer); + + if peer.expired { + println!(" Expired: {}", "Yes".red()); + if let Some(time_since) = peer.time_until_expiry { + println!(" Time Since Expiry: {}", time_since); + } + } else { + println!(" Expired: {}", "No".green()); + if let Some(time_until) = peer.time_until_expiry { + println!(" Time Until Expiry: {}", time_until); + } + } + } else { + println!("Discovered Peer: None"); + } + + Ok(()) +} diff --git a/mgadm/src/rib.rs b/mgadm/src/rib.rs index 81ff3063..21f00828 100644 --- a/mgadm/src/rib.rs +++ b/mgadm/src/rib.rs @@ -96,7 +96,7 @@ async fn get_imported( protocol: Option, ) -> Result<()> { let imported = c - .get_rib_imported(address_family.as_ref(), protocol.as_ref()) + .get_rib_imported_v2(address_family.as_ref(), protocol.as_ref()) .await? .into_inner(); @@ -110,7 +110,7 @@ async fn get_selected( protocol: Option, ) -> Result<()> { let selected = c - .get_rib_selected(address_family.as_ref(), protocol.as_ref()) + .get_rib_selected_v2(address_family.as_ref(), protocol.as_ref()) .await? .into_inner(); diff --git a/mgd/Cargo.toml b/mgd/Cargo.toml index ddda43e3..733193e2 100644 --- a/mgd/Cargo.toml +++ b/mgd/Cargo.toml @@ -4,12 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] -mg-api.workspace = true mg-lower = { path = "../mg-lower", optional = true } -mg-common = { path = "../mg-common", default-features = false} -bfd = { path = "../bfd" } -bgp = { path = "../bgp" } -rdb = { path = "../rdb" } +mg-api.workspace = true +mg-common.workspace = true +bfd.workspace = true +bgp.workspace = true +rdb.workspace = true anyhow.workspace = true clap.workspace = true slog.workspace = true @@ -22,11 +22,14 @@ oxide-tokio-rt.workspace = true oximeter.workspace = true oximeter-producer.workspace = true chrono.workspace = true +humantime.workspace = true omicron-common.workspace = true hostname.workspace = true uuid.workspace = true smf.workspace = true gateway-client.workspace = true +ndp.workspace = true +network-interface.workspace = true [dev-dependencies] tempfile = "3" diff --git a/mgd/src/admin.rs b/mgd/src/admin.rs index 1fd6b067..20524a6c 100644 --- a/mgd/src/admin.rs +++ b/mgd/src/admin.rs @@ -13,7 +13,7 @@ use dropshot::{ }; use mg_api::*; use mg_common::stats::MgLowerStats; -use rdb::{BfdPeerConfig, Db, Prefix}; +use rdb::{BfdPeerConfig, Db, PeerId, Prefix}; use slog::{Logger, error, info, o}; use std::collections::HashMap; #[cfg(feature = "mg-lower")] @@ -152,6 +152,8 @@ impl MgAdminApi for MgAdminApiImpl { bgp_admin::read_neighbors(ctx, request).await } + // Neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + async fn create_neighbor( ctx: RequestContext, request: TypedBody, @@ -161,7 +163,7 @@ impl MgAdminApi for MgAdminApiImpl { async fn read_neighbor( ctx: RequestContext, - request: Query, + request: Query, ) -> Result, HttpError> { bgp_admin::read_neighbor(ctx, request).await } @@ -175,7 +177,7 @@ impl MgAdminApi for MgAdminApiImpl { async fn delete_neighbor( ctx: RequestContext, - request: Query, + request: Query, ) -> Result { bgp_admin::delete_neighbor(ctx, request).await } @@ -196,7 +198,7 @@ impl MgAdminApi for MgAdminApiImpl { async fn read_neighbor_v2( ctx: RequestContext, - request: Query, + request: Query, ) -> Result, HttpError> { bgp_admin::read_neighbor_v2(ctx, request).await } @@ -210,11 +212,46 @@ impl MgAdminApi for MgAdminApiImpl { async fn delete_neighbor_v2( ctx: RequestContext, - request: Query, + request: Query, ) -> Result { bgp_admin::delete_neighbor_v2(ctx, request).await } + async fn create_neighbor_v3( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::create_neighbor_v3(ctx, request).await + } + + async fn read_neighbor_v3( + ctx: RequestContext, + path: Path, + ) -> Result, HttpError> { + bgp_admin::read_neighbor_v3(ctx, path).await + } + + async fn read_neighbors_v3( + ctx: RequestContext, + path: Path, + ) -> Result>, HttpError> { + bgp_admin::read_neighbors_v3(ctx, path).await + } + + async fn update_neighbor_v3( + ctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::update_neighbor_v3(ctx, request).await + } + + async fn delete_neighbor_v3( + ctx: RequestContext, + path: Path, + ) -> Result { + bgp_admin::delete_neighbor_v3(ctx, path).await + } + async fn clear_neighbor( ctx: RequestContext, request: TypedBody, @@ -229,6 +266,52 @@ impl MgAdminApi for MgAdminApiImpl { bgp_admin::clear_neighbor_v2(ctx, request).await } + // Unnumbered neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + async fn read_unnumbered_neighbors( + rqctx: RequestContext, + request: Query, + ) -> Result>, HttpError> { + bgp_admin::read_unnumbered_neighbors(rqctx, request).await + } + + async fn create_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::create_unnumbered_neighbor(rqctx, request).await + } + + async fn read_unnumbered_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result, HttpError> { + bgp_admin::read_unnumbered_neighbor(rqctx, request).await + } + + async fn update_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::update_unnumbered_neighbor(rqctx, request).await + } + + async fn delete_unnumbered_neighbor( + rqctx: RequestContext, + request: Query, + ) -> Result { + bgp_admin::delete_unnumbered_neighbor(rqctx, request).await + } + + async fn clear_unnumbered_neighbor( + rqctx: RequestContext, + request: TypedBody, + ) -> Result { + bgp_admin::clear_unnumbered_neighbor(rqctx, request).await + } + + // IPv4 origin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + async fn create_origin4( ctx: RequestContext, request: TypedBody, @@ -292,34 +375,55 @@ impl MgAdminApi for MgAdminApiImpl { bgp_admin::get_exported(ctx, request).await } + async fn get_exported_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result>>, HttpError> { + bgp_admin::get_exported_v2(ctx, request).await + } + async fn get_imported( ctx: RequestContext, request: TypedBody, - ) -> Result, HttpError> { - bgp_admin::get_imported(ctx, request).await + ) -> Result, HttpError> { + bgp_admin::get_imported_v1(ctx, request).await } async fn get_selected( ctx: RequestContext, request: TypedBody, - ) -> Result, HttpError> { - bgp_admin::get_selected(ctx, request).await + ) -> Result, HttpError> { + bgp_admin::get_selected_v1(ctx, request).await } async fn get_rib_imported( ctx: RequestContext, request: Query, - ) -> Result, HttpError> { + ) -> Result, HttpError> { rib_admin::get_rib_imported(ctx, request).await } async fn get_rib_selected( ctx: RequestContext, request: Query, - ) -> Result, HttpError> { + ) -> Result, HttpError> { rib_admin::get_rib_selected(ctx, request).await } + async fn get_rib_imported_v2( + ctx: RequestContext, + request: Query, + ) -> Result, HttpError> { + rib_admin::get_rib_imported_v2(ctx, request).await + } + + async fn get_rib_selected_v2( + ctx: RequestContext, + request: Query, + ) -> Result, HttpError> { + rib_admin::get_rib_selected_v2(ctx, request).await + } + async fn get_neighbors( ctx: RequestContext, request: Query, @@ -341,6 +445,13 @@ impl MgAdminApi for MgAdminApiImpl { bgp_admin::get_neighbors_v3(ctx, request).await } + async fn get_neighbors_v4( + ctx: RequestContext, + request: Query, + ) -> Result>, HttpError> { + bgp_admin::get_neighbors_unified(ctx, request).await + } + async fn bgp_apply( ctx: RequestContext, request: TypedBody, @@ -363,17 +474,31 @@ impl MgAdminApi for MgAdminApiImpl { } async fn message_history_v2( + ctx: RequestContext, + request: TypedBody, + ) -> Result, HttpError> { + bgp_admin::message_history_v2(ctx, request).await + } + + async fn message_history_v3( ctx: RequestContext, request: TypedBody, ) -> Result, HttpError> { - bgp_admin::message_history_v2(ctx, request).await + bgp_admin::message_history_v3(ctx, request).await } async fn fsm_history( + ctx: RequestContext, + request: TypedBody, + ) -> Result, HttpError> { + bgp_admin::fsm_history(ctx, request).await + } + + async fn fsm_history_v2( ctx: RequestContext, request: TypedBody, ) -> Result, HttpError> { - bgp_admin::fsm_history(ctx, request).await + bgp_admin::fsm_history_v2(ctx, request).await } async fn create_checker( @@ -503,6 +628,20 @@ impl MgAdminApi for MgAdminApiImpl { ) -> Result, HttpError> { static_admin::switch_identifiers(ctx).await } + + async fn get_ndp_interfaces( + ctx: RequestContext, + request: Query, + ) -> Result>, HttpError> { + bgp_admin::get_ndp_interfaces(ctx, request).await + } + + async fn get_ndp_interface_detail( + ctx: RequestContext, + request: Query, + ) -> Result, HttpError> { + bgp_admin::get_ndp_interface_detail(ctx, request).await + } } pub fn api_description() -> ApiDescription> { diff --git a/mgd/src/bgp_admin.rs b/mgd/src/bgp_admin.rs index 35de7f4a..5f3c9eca 100644 --- a/mgd/src/bgp_admin.rs +++ b/mgd/src/bgp_admin.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. #![allow(clippy::type_complexity)] +use crate::unnumbered_manager::{NdpPeerState, UnnumberedManagerNdp}; use crate::validation::{validate_prefixes_v4, validate_prefixes_v6}; use crate::{admin::HandlerContext, error::Error, log::bgp_log}; use bgp::{ @@ -14,24 +15,32 @@ use bgp::{ params::*, router::{LoadPolicyError, Router}, session::{ - AdminEvent, FsmEvent, FsmStateKind, MessageHistory, MessageHistoryV1, - SessionEndpoint, SessionInfo, + AdminEvent, ConnectionKind, FsmEvent, FsmEventRecord, FsmStateKind, + MessageHistory, MessageHistoryV1, PeerId, SessionEndpoint, SessionInfo, + SessionRunner, }, }; +use chrono::{DateTime, SecondsFormat, Utc}; use dropshot::{ ClientErrorStatusCode, HttpError, HttpResponseDeleted, HttpResponseOk, - HttpResponseUpdatedNoContent, Query, RequestContext, TypedBody, + HttpResponseUpdatedNoContent, Path, Query, RequestContext, TypedBody, }; use mg_api::{ - AsnSelector, BestpathFanoutRequest, BestpathFanoutResponse, FsmEventBuffer, - FsmHistoryRequest, FsmHistoryResponse, MessageDirection, - MessageHistoryRequest, MessageHistoryRequestV1, MessageHistoryResponse, - MessageHistoryResponseV1, NeighborResetRequest, NeighborResetRequestV1, - NeighborSelector, Rib, + AsnSelector, BestpathFanoutRequest, BestpathFanoutResponse, + ExportedSelector, FsmEventBuffer, FsmHistoryRequest, FsmHistoryRequestV4, + FsmHistoryResponse, FsmHistoryResponseV4, MessageDirection, + MessageHistoryRequest, MessageHistoryRequestV1, MessageHistoryRequestV4, + MessageHistoryResponse, MessageHistoryResponseV1, MessageHistoryResponseV4, + NdpInterface, NdpInterfaceSelector, NdpPeer, NeighborResetRequest, + NeighborResetRequestV1, NeighborSelector, NeighborSelectorV1, RibV1, + UnnumberedNeighborResetRequest, UnnumberedNeighborSelector, }; use mg_common::lock; -use rdb::{AddressFamily, Asn, BgpRouterInfo, ImportExportPolicyV1, Prefix}; -use rdb::{ImportExportPolicy4, ImportExportPolicy6}; +use rdb::{ + AddressFamily, Asn, BgpRouterInfo, ImportExportPolicy4, + ImportExportPolicy6, ImportExportPolicyV1, Prefix, Prefix4, Prefix6, +}; +use slog::Logger; use std::collections::{BTreeMap, HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}; @@ -39,7 +48,7 @@ use std::sync::{ Arc, Mutex, mpsc::{Sender, channel}, }; -use std::time::Duration; +use std::time::{Duration, Instant, SystemTime}; const UNIT_BGP: &str = "bgp"; const DEFAULT_BGP_LISTEN: SocketAddr = @@ -48,19 +57,24 @@ const DEFAULT_BGP_LISTEN: SocketAddr = #[derive(Clone)] pub struct BgpContext { pub(crate) router: Arc>>>>, - addr_to_session: - Arc>>>, + peer_to_session: + Arc>>>, + pub(crate) unnumbered_manager: Arc, } impl BgpContext { pub fn new( - addr_to_session: Arc< - Mutex>>, + peer_to_session: Arc< + Mutex>>, >, + log: Logger, ) -> Self { + let router = Arc::new(Mutex::new(BTreeMap::new())); + let unnumbered_manager = UnnumberedManagerNdp::new(router.clone(), log); Self { - router: Arc::new(Mutex::new(BTreeMap::new())), - addr_to_session, + router, + peer_to_session, + unnumbered_manager, } } } @@ -166,6 +180,29 @@ pub async fn delete_router( Ok(HttpResponseUpdatedNoContent()) } +// Neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +pub async fn read_neighbors_v2( + ctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + let nbrs = ctx + .db + .get_bgp_neighbors() + .map_err(|e| HttpError::for_internal_error(e.to_string()))?; + + let result = nbrs + .into_iter() + .filter(|x| x.asn == rq.asn) + .map(|x| Neighbor::from_rdb_neighbor_info(rq.asn, &x)) + .collect(); + + Ok(HttpResponseOk(result)) +} + pub async fn read_neighbors( ctx: RequestContext>, request: Query, @@ -199,7 +236,7 @@ pub async fn create_neighbor( pub async fn read_neighbor( ctx: RequestContext>, - request: Query, + request: Query, ) -> Result, HttpError> { let rq = request.into_inner(); let db_neighbors = ctx.context().db.get_bgp_neighbors().map_err(|e| { @@ -229,7 +266,7 @@ pub async fn update_neighbor( pub async fn delete_neighbor( ctx: RequestContext>, - request: Query, + request: Query, ) -> Result { let rq = request.into_inner(); let ctx = ctx.context(); @@ -257,28 +294,6 @@ pub async fn clear_neighbor_v2( Ok(helpers::reset_neighbor(ctx.clone(), rq).await?) } -// V3 API handlers (new Neighbor type with optional per-AF configs) -pub async fn read_neighbors_v2( - ctx: RequestContext>, - request: Query, -) -> Result>, HttpError> { - let rq = request.into_inner(); - let ctx = ctx.context(); - - let nbrs = ctx - .db - .get_bgp_neighbors() - .map_err(|e| HttpError::for_internal_error(e.to_string()))?; - - let result = nbrs - .into_iter() - .filter(|x| x.asn == rq.asn) - .map(|x| Neighbor::from_rdb_neighbor_info(rq.asn, &x)) - .collect(); - - Ok(HttpResponseOk(result)) -} - pub async fn create_neighbor_v2( ctx: RequestContext>, request: TypedBody, @@ -291,7 +306,7 @@ pub async fn create_neighbor_v2( pub async fn read_neighbor_v2( ctx: RequestContext>, - request: Query, + request: Query, ) -> Result, HttpError> { let rq = request.into_inner(); let db_neighbors = ctx.context().db.get_bgp_neighbors().map_err(|e| { @@ -321,13 +336,382 @@ pub async fn update_neighbor_v2( pub async fn delete_neighbor_v2( ctx: RequestContext>, - request: Query, + request: Query, ) -> Result { let rq = request.into_inner(); let ctx = ctx.context(); Ok(helpers::remove_neighbor(ctx.clone(), rq.asn, rq.addr).await?) } +// V3 API - Unified neighbor operations supporting both numbered and unnumbered +pub async fn create_neighbor_v3( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + // Delegate to v2 - create operation doesn't depend on selector type + create_neighbor_v2(ctx, request).await +} + +pub async fn read_neighbor_v3( + ctx: RequestContext>, + path: Path, +) -> Result, HttpError> { + let rq = path.into_inner(); + let peer_id = rq.to_peer_id(); + + match peer_id { + PeerId::Ip(addr) => { + // Numbered peer - query numbered neighbors DB + let db_neighbors = + ctx.context().db.get_bgp_neighbors().map_err(|e| { + HttpError::for_internal_error(format!( + "get neighbors kv tree: {e}" + )) + })?; + let neighbor_info = db_neighbors + .iter() + .find(|n| n.host.ip() == addr) + .ok_or(HttpError::for_not_found( + None, + format!("neighbor {} not found in db", addr), + ))?; + let result = + Neighbor::from_rdb_neighbor_info(rq.asn, neighbor_info); + Ok(HttpResponseOk(result)) + } + PeerId::Interface(ref iface) => { + // Unnumbered peer - query unnumbered neighbors DB + let db_neighbors = + ctx.context().db.get_unnumbered_bgp_neighbors().map_err( + |e| { + HttpError::for_internal_error(format!( + "get unnumbered neighbors kv tree: {e}" + )) + }, + )?; + let neighbor_info = db_neighbors + .iter() + .find(|n| &n.interface == iface) + .ok_or(HttpError::for_not_found( + None, + format!("neighbor {} not found in db", iface), + ))?; + let result = UnnumberedNeighbor::from_rdb_neighbor_info( + rq.asn, + neighbor_info, + ); + // Convert UnnumberedNeighbor to Neighbor + Ok(HttpResponseOk(Neighbor { + asn: result.asn, + name: result.name, + group: result.group, + host: std::net::SocketAddr::new( + std::net::IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), + 179, + ), + parameters: result.parameters, + })) + } + } +} + +pub async fn read_neighbors_v3( + ctx: RequestContext>, + path: Path, +) -> Result>, HttpError> { + let rq = path.into_inner(); + let ctx = ctx.context(); + + let nbrs = ctx + .db + .get_bgp_neighbors() + .map_err(|e| HttpError::for_internal_error(e.to_string()))?; + + let result = nbrs + .into_iter() + .filter(|x| x.asn == rq.asn) + .map(|x| Neighbor::from_rdb_neighbor_info(rq.asn, &x)) + .collect(); + + Ok(HttpResponseOk(result)) +} + +pub async fn update_neighbor_v3( + ctx: RequestContext>, + request: TypedBody, +) -> Result { + // Delegate to v2 - update operation doesn't depend on selector type + update_neighbor_v2(ctx, request).await +} + +pub async fn delete_neighbor_v3( + ctx: RequestContext>, + path: Path, +) -> Result { + let rq = path.into_inner(); + let peer_id = rq.to_peer_id(); + let ctx = ctx.context(); + + match peer_id { + PeerId::Ip(addr) => { + Ok(helpers::remove_neighbor(ctx.clone(), rq.asn, addr).await?) + } + PeerId::Interface(ref iface) => Ok( + helpers::remove_unnumbered_neighbor(ctx.clone(), rq.asn, iface) + .await?, + ), + } +} + +// Unnumbered neighbors ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +pub async fn read_unnumbered_neighbors( + rqctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = rqctx.context(); + + let nbrs = ctx + .db + .get_unnumbered_bgp_neighbors() + .map_err(|e| HttpError::for_internal_error(e.to_string()))?; + + let result = nbrs + .into_iter() + .filter(|x| x.asn == rq.asn) + .map(|x| UnnumberedNeighbor::from_rdb_neighbor_info(rq.asn, &x)) + .collect(); + + Ok(HttpResponseOk(result)) +} + +pub async fn create_unnumbered_neighbor( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = rqctx.context(); + helpers::add_unnumbered_neighbor(ctx.clone(), rq, false)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn read_unnumbered_neighbor( + rqctx: RequestContext>, + request: Query, +) -> Result, HttpError> { + let rq = request.into_inner(); + let db_neighbors = rqctx + .context() + .db + .get_unnumbered_bgp_neighbors() + .map_err(|e| { + HttpError::for_internal_error(format!("get neighbors kv tree: {e}")) + })?; + let neighbor_info = db_neighbors + .iter() + .find(|n| n.interface == rq.interface) + .ok_or(HttpError::for_not_found( + None, + format!("neighbor {} not found in db", rq.interface), + ))?; + + let result = + UnnumberedNeighbor::from_rdb_neighbor_info(rq.asn, neighbor_info); + Ok(HttpResponseOk(result)) +} + +pub async fn update_unnumbered_neighbor( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = rqctx.context(); + helpers::add_unnumbered_neighbor(ctx.clone(), rq, true)?; + Ok(HttpResponseUpdatedNoContent()) +} + +pub async fn delete_unnumbered_neighbor( + rqctx: RequestContext>, + request: Query, +) -> Result { + let rq = request.into_inner(); + let ctx = rqctx.context(); + Ok( + helpers::remove_unnumbered_neighbor(ctx.clone(), rq.asn, &rq.interface) + .await?, + ) +} + +pub async fn clear_unnumbered_neighbor( + rqctx: RequestContext>, + request: TypedBody, +) -> Result { + let rq = request.into_inner(); + let ctx = rqctx.context(); + Ok(helpers::reset_unnumbered_neighbor( + ctx.clone(), + rq.asn, + &rq.interface, + rq.op, + ) + .await?) +} + +/// Convert an Instant to an ISO 8601 timestamp string +fn instant_to_iso8601(when: Instant) -> String { + let now_instant = Instant::now(); + let now_system = SystemTime::now(); + let elapsed = now_instant.duration_since(when); + let system_time = now_system - elapsed; + DateTime::::from(system_time) + .to_rfc3339_opts(SecondsFormat::Secs, true) +} + +/// Convert NdpPeerState to API type with timestamp formatting +fn convert_ndp_peer_to_api(state: &NdpPeerState) -> NdpPeer { + let elapsed_since_when = Instant::now().duration_since(state.when); + + // Format timestamps: first_seen for when peer was discovered, + // when for when the most recent RA was received + let discovered_at = instant_to_iso8601(state.first_seen); + let last_advertisement = instant_to_iso8601(state.when); + + // Calculate time until expiry + let effective_lifetime = + Duration::from_secs(u64::from(state.router_lifetime)); + let time_until_expiry = if state.expired { + // Calculate time since expiry + let time_since_expiry = elapsed_since_when + .checked_sub(effective_lifetime) + .unwrap_or(Duration::ZERO); + Some(format!("{}", humantime::format_duration(time_since_expiry))) + } else { + // Calculate time until expiry + let time_until = effective_lifetime + .checked_sub(elapsed_since_when) + .unwrap_or(Duration::ZERO); + Some(format!("{}", humantime::format_duration(time_until))) + }; + + NdpPeer { + address: state.address, + discovered_at, + last_advertisement, + router_lifetime: state.router_lifetime, + reachable_time: state.reachable_time, + retrans_timer: state.retrans_timer, + expired: state.expired, + time_until_expiry, + } +} + +pub async fn get_ndp_interfaces( + rqctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = rqctx.context(); + + // Get all unnumbered neighbors for this ASN + let unnumbered_neighbors = ctx + .db + .get_unnumbered_bgp_neighbors() + .map_err(|e| { + HttpError::for_internal_error(format!( + "failed to get unnumbered neighbors: {e}" + )) + })? + .into_iter() + .filter(|n| n.asn == rq.asn) + .collect::>(); + + // Get NDP state for managed interfaces + let ndp_state = ctx.bgp.unnumbered_manager.list_ndp_interfaces(); + + // Build response by matching neighbors to NDP state + let mut result = Vec::new(); + for neighbor in unnumbered_neighbors { + // Find NDP state for this interface + if let Some(ndp) = ndp_state + .iter() + .find(|info| info.interface == neighbor.interface) + { + let discovered_peer = + ndp.peer_state.as_ref().map(convert_ndp_peer_to_api); + + result.push(NdpInterface { + interface: neighbor.interface.clone(), + local_address: ndp.local_address, + scope_id: ndp.scope_id, + router_lifetime: neighbor.router_lifetime, + discovered_peer, + }); + } + } + + Ok(HttpResponseOk(result)) +} + +pub async fn get_ndp_interface_detail( + rqctx: RequestContext>, + request: Query, +) -> Result, HttpError> { + let rq = request.into_inner(); + let ctx = rqctx.context(); + + // Verify this interface has an unnumbered neighbor configured for this ASN + let neighbor = ctx + .db + .get_unnumbered_bgp_neighbors() + .map_err(|e| { + HttpError::for_internal_error(format!( + "failed to get unnumbered neighbors: {e}" + )) + })? + .into_iter() + .find(|n| n.asn == rq.asn && n.interface == rq.interface) + .ok_or_else(|| { + HttpError::for_not_found( + None, + format!( + "no unnumbered neighbor for ASN {} on interface {}", + rq.asn, rq.interface + ), + ) + })?; + + // Get detailed NDP state + let unnumbered_manager = &ctx.bgp.unnumbered_manager; + + let ndp_detail = unnumbered_manager + .get_ndp_interface_detail(&rq.interface) + .map_err(|e| { + HttpError::for_internal_error(format!( + "failed to get NDP state: {e}" + )) + })? + .ok_or_else(|| { + HttpError::for_not_found( + None, + format!("interface {} not managed by NDP", rq.interface), + ) + })?; + + let discovered_peer = + ndp_detail.peer_state.as_ref().map(convert_ndp_peer_to_api); + + Ok(HttpResponseOk(NdpInterface { + interface: rq.interface, + local_address: ndp_detail.local_address, + scope_id: ndp_detail.scope_id, + router_lifetime: neighbor.router_lifetime, + discovered_peer, + })) +} + +// IPv4 origin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + pub async fn create_origin4( ctx: RequestContext>, request: TypedBody, @@ -472,6 +856,7 @@ pub async fn delete_origin6( Ok(HttpResponseDeleted()) } +// Legacy endpoint (pre MP-BGP/unnumbered): IPv4 only, no filtering pub async fn get_exported( ctx: RequestContext>, request: TypedBody, @@ -488,23 +873,26 @@ pub async fn get_exported( let mut exported = HashMap::new(); for n in neighs { - if r.get_session(n.host.ip()) + let ip = n.host.ip(); + + if !ip.is_ipv4() { + continue; + } + + if r.get_session(ip) .filter(|s| s.state() == FsmStateKind::Established) .is_none() { continue; } - let mut orig_routes: Vec = orig4 - .clone() - .iter() - .map(|p| rdb::Prefix::from(*p)) - .collect(); + let mut orig_routes: Vec = + orig4.clone().iter().map(|p| Prefix::from(*p)).collect(); // Combine per-AF export policies into legacy format for filtering let allow_export = ImportExportPolicyV1::from_per_af_policies( - &n.allow_export4, - &n.allow_export6, + &n.parameters.allow_export4, + &n.parameters.allow_export6, ); let mut exported_routes: Vec = match allow_export { ImportExportPolicyV1::NoFiltering => orig_routes, @@ -522,10 +910,65 @@ pub async fn get_exported( Ok(HttpResponseOk(exported)) } -pub async fn get_imported( +// MP-BGP + BGP unnumbered +pub async fn get_exported_v2( + ctx: RequestContext>, + request: TypedBody, +) -> Result>>, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + let r = get_router!(ctx, rq.asn)?.clone(); + + // Get originated prefixes for both address families + let orig4 = r.db.get_origin4().map_err(|e| { + HttpError::for_internal_error(format!("error getting origin4: {e}")) + })?; + let orig6 = r.db.get_origin6().map_err(|e| { + HttpError::for_internal_error(format!("error getting origin6: {e}")) + })?; + + // Determine which address families to process + let process_ipv4 = rq.afi.is_none() || rq.afi == Some(Afi::Ipv4); + let process_ipv6 = rq.afi.is_none() || rq.afi == Some(Afi::Ipv6); + + let mut exported = HashMap::new(); + + if let Some(ref peer_filter) = rq.peer { + // Specific peer requested - look it up directly + if let Some(session) = r.get_session(peer_filter.clone()) + && let Some((peer_key, routes)) = helpers::get_exported( + &session, + &orig4, + &orig6, + process_ipv4, + process_ipv6, + ) + { + exported.insert(peer_key, routes); + } + } else { + // No peer filter - iterate all sessions + for session in lock!(r.sessions).values() { + if let Some((peer_key, routes)) = helpers::get_exported( + session, + &orig4, + &orig6, + process_ipv4, + process_ipv6, + ) { + exported.insert(peer_key, routes); + } + } + } + + Ok(HttpResponseOk(exported)) +} + +// Pre-UNNUMBERED versions (BgpPathProperties.peer is IpAddr) +pub async fn get_imported_v1( ctx: RequestContext>, request: TypedBody, -) -> Result, HttpError> { +) -> Result, HttpError> { let rq = request.into_inner(); let ctx = ctx.context(); let imported = get_router!(ctx, rq.asn)? @@ -534,10 +977,10 @@ pub async fn get_imported( Ok(HttpResponseOk(imported.into())) } -pub async fn get_selected( +pub async fn get_selected_v1( ctx: RequestContext>, request: TypedBody, -) -> Result, HttpError> { +) -> Result, HttpError> { let rq = request.into_inner(); let ctx = ctx.context(); let selected = get_router!(ctx, rq.asn)? @@ -601,7 +1044,11 @@ pub async fn get_neighbors( }, }; - peers.insert(s.neighbor.host.ip(), PeerInfoV1::from(pi)); + let peer_ip = match s.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => continue, // Skip unnumbered sessions for V1 API + }; + peers.insert(peer_ip, PeerInfoV1::from(pi)); } Ok(HttpResponseOk(peers)) @@ -643,8 +1090,12 @@ pub async fn get_neighbors_v2( ) }; + let peer_ip = match s.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => continue, // Skip unnumbered sessions for V2 API + }; peers.insert( - s.neighbor.host.ip(), + peer_ip, PeerInfoV2 { state: s.state(), asn: s.remote_asn(), @@ -686,13 +1137,44 @@ pub async fn get_neighbors_v3( }; for s in sessions.iter() { - let peer_ip = s.neighbor.host.ip(); + let peer_ip = match s.neighbor.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(_) => continue, // Skip unnumbered sessions + }; peers.insert(peer_ip, s.get_peer_info()); } Ok(HttpResponseOk(peers)) } +pub async fn get_neighbors_unified( + ctx: RequestContext>, + request: Query, +) -> Result>, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + let mut peers = HashMap::new(); + + // Clone sessions while holding locks, then release them + let sessions: Vec<_> = { + let routers = lock!(ctx.bgp.router); + let r = routers.get(&rq.asn).ok_or(HttpError::for_not_found( + None, + "ASN not found".to_string(), + ))?; + lock!(r.sessions).values().cloned().collect() + }; + + for s in sessions.iter() { + // Use PeerId Display impl as HashMap key + let peer_key = s.neighbor.peer.to_string(); + peers.insert(peer_key, s.get_peer_info()); + } + + Ok(HttpResponseOk(peers)) +} + pub async fn bgp_apply( ctx: RequestContext>, request: TypedBody, @@ -739,6 +1221,24 @@ async fn do_bgp_apply( } } + #[derive(Debug, Eq)] + struct Unbr { + interface: String, + asn: u32, + } + + impl Hash for Unbr { + fn hash(&self, state: &mut H) { + self.interface.hash(state); + } + } + + impl PartialEq for Unbr { + fn eq(&self, other: &Unbr) -> bool { + self.interface.eq(&other.interface) + } + } + let groups = ctx .db .get_bgp_neighbors() @@ -756,6 +1256,121 @@ async fn do_bgp_apply( peers.insert(g.clone(), Vec::default()); } } + let mut upeers = rq.unnumbered_peers.clone(); + for g in &groups { + if !upeers.contains_key(g) { + upeers.insert(g.clone(), Vec::default()); + } + } + + helpers::ensure_router( + ctx.clone(), + bgp::params::Router { + asn: rq.asn, + id: rq.asn, + listen: DEFAULT_BGP_LISTEN.to_string(), //TODO as parameter + graceful_shutdown: false, // TODO as parameter + }, + ) + .await?; + + for (group, peers) in &upeers { + let current: Vec = ctx + .db + .get_unnumbered_bgp_neighbors() + .map_err(Error::Db)? + .into_iter() + .filter(|x| &x.group == group) + .collect(); + + let current_unbr_ifxs: HashSet = current + .iter() + .map(|x| Unbr { + interface: x.interface.clone(), + asn: x.asn, + }) + .collect(); + + let specified_unbr_ifxs: HashSet = peers + .iter() + .map(|x| Unbr { + interface: x.interface.clone(), + asn: rq.asn, + }) + .collect(); + + let to_delete = current_unbr_ifxs.difference(&specified_unbr_ifxs); + let to_add = specified_unbr_ifxs.difference(¤t_unbr_ifxs); + let to_modify = current_unbr_ifxs.intersection(&specified_unbr_ifxs); + + bgp_log!(log, info, "unbr: current {current:#?}"); + bgp_log!(log, info, "unbr: adding {to_add:#?}"); + bgp_log!(log, info, "unbr: removing {to_delete:#?}"); + + let mut nbr_config = Vec::new(); + for nbr in to_add { + let cfg = peers + .iter() + .find(|x| x.interface == nbr.interface) + .ok_or(Error::NotFound(nbr.interface.clone()))?; + nbr_config.push((nbr, cfg)); + } + + for nbr in to_modify { + let spec = peers + .iter() + .find(|x| x.interface == nbr.interface) + .ok_or(Error::NotFound(nbr.interface.clone()))?; + + let tgt = UnnumberedNeighbor::from_bgp_peer_config( + nbr.asn, + group.clone(), + spec.clone(), + ); + + let curr = UnnumberedNeighbor::from_rdb_neighbor_info( + nbr.asn, + current + .iter() + .find(|x| x.interface == nbr.interface) + .ok_or(Error::NotFound(nbr.interface.clone()))?, + ); + + if tgt != curr { + nbr_config.push((nbr, spec)); + } + } + + for (nbr, cfg) in nbr_config { + helpers::add_unnumbered_neighbor( + ctx.clone(), + UnnumberedNeighbor::from_bgp_peer_config( + nbr.asn, + group.clone(), + cfg.clone(), + ), + true, // ensure mode: create or update as needed + )?; + } + + for nbr in to_delete { + helpers::remove_unnumbered_neighbor( + ctx.clone(), + nbr.asn, + &nbr.interface, + ) + .await?; + + let mut routers = lock!(ctx.bgp.router); + let mut remove = false; + if let Some(r) = routers.get(&nbr.asn) { + remove = lock!(r.sessions).is_empty(); + } + if remove && let Some(r) = routers.remove(&nbr.asn) { + r.shutdown() + }; + } + } for (group, peers) in &peers { let current: Vec = ctx @@ -827,17 +1442,6 @@ async fn do_bgp_apply( // TODO all the db modification that happens below needs to happen in a // transaction. - helpers::ensure_router( - ctx.clone(), - bgp::params::Router { - asn: rq.asn, - id: rq.asn, - listen: DEFAULT_BGP_LISTEN.to_string(), //TODO as parameter - graceful_shutdown: false, // TODO as parameter - }, - ) - .await?; - for (nbr, cfg) in nbr_config { helpers::add_neighbor( ctx.clone(), @@ -871,32 +1475,34 @@ async fn do_bgp_apply( Ok(HttpResponseUpdatedNoContent()) } -// Common helper for fetching message history with optional filtering +// Helper for fetching message history with PeerId filtering +// Returns HashMap with string keys using PeerId Display format fn get_message_history_filtered( ctx: &Arc, asn: u32, - peer: Option, + peer: Option, direction: Option, -) -> Result, HttpError> { +) -> Result, HttpError> { let mut result = HashMap::new(); // Determine which peers to fetch history for - let peers_to_query: Vec = if let Some(peer_addr) = peer { - if lock!(get_router!(ctx, asn)?.sessions).contains_key(&peer_addr) { - vec![peer_addr] + let peers_to_query: Vec = if let Some(peer_id) = peer { + if lock!(get_router!(ctx, asn)?.sessions).contains_key(&peer_id) { + vec![peer_id] } else { vec![] } } else { lock!(get_router!(ctx, asn)?.sessions) .keys() - .copied() + .cloned() .collect() }; // Fetch history for each peer - for addr in peers_to_query { - if let Some(session) = lock!(get_router!(ctx, asn)?.sessions).get(&addr) + for peer_id in peers_to_query { + if let Some(session) = + lock!(get_router!(ctx, asn)?.sessions).get(&peer_id) { let mut history = lock!(session.message_history).clone(); @@ -912,7 +1518,8 @@ fn get_message_history_filtered( } } - result.insert(addr, history); + // Use PeerId Display impl as HashMap key + result.insert(peer_id.to_string(), history); } } @@ -928,15 +1535,45 @@ pub async fn message_history( let mut result = HashMap::new(); - for (addr, session) in lock!(get_router!(ctx, rq.asn)?.sessions).iter() { - let mh = lock!(session.message_history).clone(); - result.insert(*addr, MessageHistoryV1::from(mh)); + for (key, session) in lock!(get_router!(ctx, rq.asn)?.sessions).iter() { + // Only include IP-based sessions in the history + if let PeerId::Ip(addr) = key { + let mh = lock!(session.message_history).clone(); + result.insert(*addr, MessageHistoryV1::from(mh)); + } } Ok(HttpResponseOk(MessageHistoryResponseV1 { by_peer: result })) } +// Pre-UNNUMBERED API endpoint (VERSION_IPV6_BASIC..VERSION_UNNUMBERED) +// Uses IpAddr for numbered peers only pub async fn message_history_v2( + ctx: RequestContext>, + request: TypedBody, +) -> Result, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + // Convert IpAddr filter to PeerId and call unified helper + let peer_id = rq.peer.map(PeerId::Ip); + let by_peer_string = + get_message_history_filtered(ctx, rq.asn, peer_id, rq.direction)?; + + // Convert String keys back to IpAddr (filters out unnumbered peers) + let by_peer = by_peer_string + .into_iter() + .filter_map(|(key, history)| { + key.parse::().ok().map(|addr| (addr, history)) + }) + .collect(); + + Ok(HttpResponseOk(MessageHistoryResponseV4 { by_peer })) +} + +// UNNUMBERED+ API endpoint (VERSION_UNNUMBERED..) +// Uses PeerId enum for both numbered and unnumbered peers +pub async fn message_history_v3( ctx: RequestContext>, request: TypedBody, ) -> Result, HttpError> { @@ -948,22 +1585,20 @@ pub async fn message_history_v2( Ok(HttpResponseOk(MessageHistoryResponse { by_peer })) } -// FSM event history handler -pub async fn fsm_history( - ctx: RequestContext>, - request: TypedBody, -) -> Result, HttpError> { - let rq = request.into_inner(); - let ctx = ctx.context(); +/// Unified helper for FSM history retrieval. +/// Returns HashMap with String keys (PeerId Display representation). +fn get_fsm_history_filtered( + ctx: &Arc, + asn: u32, + peer: Option, + buffer: Option, +) -> Result>, HttpError> { let mut result = HashMap::new(); + let use_all_buffer = matches!(buffer, Some(FsmEventBuffer::All)); - // Determine which buffer to use (default to major) - let use_all_buffer = matches!(rq.buffer, Some(FsmEventBuffer::All)); - - // Filter by specific peer if requested - if let Some(peer_addr) = rq.peer { + if let Some(peer_id) = peer { if let Some(session) = - lock!(get_router!(ctx, rq.asn)?.sessions).get(&peer_addr) + lock!(get_router!(ctx, asn)?.sessions).get(&peer_id) { let full_history = lock!(session.fsm_event_history).clone(); let events = if use_all_buffer { @@ -971,11 +1606,10 @@ pub async fn fsm_history( } else { full_history.major.into_iter().collect() }; - result.insert(peer_addr, events); + result.insert(peer_id.to_string(), events); } } else { - // Return history for all peers - for (addr, session) in lock!(get_router!(ctx, rq.asn)?.sessions).iter() + for (peer_id, session) in lock!(get_router!(ctx, asn)?.sessions).iter() { let full_history = lock!(session.fsm_event_history).clone(); let events = if use_all_buffer { @@ -983,11 +1617,49 @@ pub async fn fsm_history( } else { full_history.major.into_iter().collect() }; - result.insert(*addr, events); + result.insert(peer_id.to_string(), events); } } - Ok(HttpResponseOk(FsmHistoryResponse { by_peer: result })) + Ok(result) +} + +// Original API endpoint (VERSION_IPV6_BASIC..VERSION_UNNUMBERED) +// FSM event history for numbered peers only +pub async fn fsm_history( + ctx: RequestContext>, + request: TypedBody, +) -> Result, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + // Convert IpAddr filter to PeerId and call unified helper + let peer_id = rq.peer.map(PeerId::Ip); + let by_peer_string = + get_fsm_history_filtered(ctx, rq.asn, peer_id, rq.buffer)?; + + // Convert String keys back to IpAddr (filters out unnumbered peers) + let by_peer = by_peer_string + .into_iter() + .filter_map(|(key, history): (String, Vec)| { + key.parse::().ok().map(|addr| (addr, history)) + }) + .collect(); + + Ok(HttpResponseOk(FsmHistoryResponseV4 { by_peer })) +} + +// V2 API endpoint (VERSION_UNNUMBERED..) +// FSM event history for all peers (numbered and unnumbered) +pub async fn fsm_history_v2( + ctx: RequestContext>, + request: TypedBody, +) -> Result, HttpError> { + let rq = request.into_inner(); + let ctx = ctx.context(); + + let by_peer = get_fsm_history_filtered(ctx, rq.asn, rq.peer, rq.buffer)?; + Ok(HttpResponseOk(FsmHistoryResponse { by_peer })) } pub async fn create_checker( @@ -1123,6 +1795,7 @@ pub async fn update_bestpath_fanout( pub(crate) mod helpers { use bgp::router::{EnsureSessionResult, UnloadPolicyError}; + use rdb::BgpNeighborParameters; use super::*; @@ -1146,13 +1819,39 @@ pub(crate) mod helpers { ) -> Result { bgp_log!(ctx.log, info, "remove neighbor (addr {addr}, asn {asn})"); - ctx.db.remove_bgp_prefixes_from_peer(&addr); + ctx.db.remove_bgp_prefixes_from_peer(&PeerId::Ip(addr)); ctx.db.remove_bgp_neighbor(addr)?; get_router!(&ctx, asn)?.delete_session(addr); Ok(HttpResponseDeleted()) } + pub(crate) async fn remove_unnumbered_neighbor( + ctx: Arc, + asn: u32, + interface: &str, + ) -> Result { + bgp_log!( + ctx.log, + info, + "remove unnumbered neighbor (interface {interface}, asn {asn})" + ); + + // Delete the BGP session for this unnumbered neighbor. + // Unnumbered sessions are keyed by interface name, not IP address. + get_router!(&ctx, asn)? + .delete_session(PeerId::Interface(interface.to_string())); + + // If the neighbor manager is running for this interface, remove + // the neighbor + ctx.bgp.unnumbered_manager.remove_neighbor(asn, interface)?; + + // And now clear out the top level database entry + ctx.db.remove_unnumbered_bgp_neighbor(interface)?; + + Ok(HttpResponseDeleted()) + } + pub(crate) fn add_neighbor_v1( ctx: Arc, rq: NeighborV1, @@ -1166,44 +1865,10 @@ pub(crate) mod helpers { let (event_tx, event_rx) = channel(); // V1 API is IPv4-only; extract only IPv4 policies - let allow_import4 = rq.allow_import.as_ipv4_policy(); - let allow_export4 = rq.allow_export.as_ipv4_policy(); - - // XXX: Do we really want both rq and info? - // SessionInfo and Neighbor types could probably be merged. - let info = SessionInfo { - passive_tcp_establishment: rq.passive, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, - md5_auth_key: rq.md5_auth_key.clone(), - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities.clone().into_iter().collect(), - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - // V1 API is IPv4-only; IPv6 support didn't exist in legacy API - ipv4_unicast: Some(Ipv4UnicastConfig { - nexthop: None, - import_policy: allow_import4.clone(), - export_policy: allow_export4.clone(), - }), - ipv6_unicast: None, - vlan_id: rq.vlan_id, - remote_id: None, - bind_addr: None, - connect_retry_time: Duration::from_secs(rq.connect_retry), - keepalive_time: Duration::from_secs(rq.keepalive), - hold_time: Duration::from_secs(rq.hold_time), - idle_hold_time: Duration::from_secs(rq.idle_hold_time), - delay_open_time: Duration::from_secs(rq.delay_open), - resolution: Duration::from_millis(rq.resolution), - // insert default values for fields not present in the v1 API - idle_hold_jitter: None, - connect_retry_jitter: Some(JitterRange { - min: 0.75, - max: 1.0, - }), - deterministic_collision_resolution: false, - }; + let allow_import4 = rq.parameters.allow_import.as_ipv4_policy(); + let allow_export4 = rq.parameters.allow_export.as_ipv4_policy(); + + let info = SessionInfo::from(&rq.parameters); let start_session = if ensure { match get_router!(&ctx, rq.asn)?.ensure_session( @@ -1229,34 +1894,38 @@ pub(crate) mod helpers { ctx.db.add_bgp_neighbor(rdb::BgpNeighborInfo { asn: rq.asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, name: rq.name.clone(), - host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, group: rq.group.clone(), - passive: rq.passive, - md5_auth_key: rq.md5_auth_key, - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities, - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - allow_import4, - allow_export4, - vlan_id: rq.vlan_id, - - // V1 API is IPv4-only and doesn't support nexthop override - ipv4_enabled: true, - ipv6_enabled: false, - allow_import6: ImportExportPolicy6::NoFiltering, - allow_export6: ImportExportPolicy6::NoFiltering, - nexthop4: None, - nexthop6: None, + host: rq.host, + parameters: BgpNeighborParameters { + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + passive: rq.parameters.passive, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + md5_auth_key: rq.parameters.md5_auth_key, + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities, + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + allow_import4, + allow_export4, + vlan_id: rq.parameters.vlan_id, + + // V1 API is IPv4-only and doesn't support nexthop override + ipv4_enabled: true, + ipv6_enabled: false, + allow_import6: ImportExportPolicy6::NoFiltering, + allow_export6: ImportExportPolicy6::NoFiltering, + nexthop4: None, + nexthop6: None, + }, })?; if start_session { @@ -1280,37 +1949,9 @@ pub(crate) mod helpers { rq.validate_address_families() .map_err(Error::InvalidRequest)?; - // Validate nexthop address families - rq.validate_nexthop().map_err(Error::InvalidRequest)?; - let (event_tx, event_rx) = channel(); - // Build SessionInfo with optional per-AF config directly from the new Neighbor type - let info = SessionInfo { - passive_tcp_establishment: rq.passive, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, - md5_auth_key: rq.md5_auth_key.clone(), - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities.clone().into_iter().collect(), - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - ipv4_unicast: rq.ipv4_unicast.clone(), - ipv6_unicast: rq.ipv6_unicast.clone(), - vlan_id: rq.vlan_id, - remote_id: None, - bind_addr: None, - connect_retry_time: Duration::from_secs(rq.connect_retry), - keepalive_time: Duration::from_secs(rq.keepalive), - hold_time: Duration::from_secs(rq.hold_time), - idle_hold_time: Duration::from_secs(rq.idle_hold_time), - delay_open_time: Duration::from_secs(rq.delay_open), - resolution: Duration::from_millis(rq.resolution), - idle_hold_jitter: rq.idle_hold_jitter, - connect_retry_jitter: rq.connect_retry_jitter, - deterministic_collision_resolution: rq - .deterministic_collision_resolution, - }; + let info = SessionInfo::from(&rq.parameters); let start_session = if ensure { match get_router!(&ctx, rq.asn)?.ensure_session( @@ -1335,61 +1976,66 @@ pub(crate) mod helpers { }; // Extract per-AF policies and nexthop for database storage - let (allow_import4, allow_export4, nexthop4) = match &rq.ipv4_unicast { - Some(cfg) => ( - cfg.import_policy.clone(), - cfg.export_policy.clone(), - cfg.nexthop, - ), - None => ( - ImportExportPolicy4::NoFiltering, - ImportExportPolicy4::NoFiltering, - None, - ), - }; + let (allow_import4, allow_export4, nexthop4) = + match &rq.parameters.ipv4_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), + None => ( + ImportExportPolicy4::NoFiltering, + ImportExportPolicy4::NoFiltering, + None, + ), + }; - let (allow_import6, allow_export6, nexthop6) = match &rq.ipv6_unicast { - Some(cfg) => ( - cfg.import_policy.clone(), - cfg.export_policy.clone(), - cfg.nexthop, - ), - None => ( - ImportExportPolicy6::NoFiltering, - ImportExportPolicy6::NoFiltering, - None, - ), - }; + let (allow_import6, allow_export6, nexthop6) = + match &rq.parameters.ipv6_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), + None => ( + ImportExportPolicy6::NoFiltering, + ImportExportPolicy6::NoFiltering, + None, + ), + }; ctx.db.add_bgp_neighbor(rdb::BgpNeighborInfo { asn: rq.asn, - remote_asn: rq.remote_asn, - min_ttl: rq.min_ttl, + group: rq.group.clone(), name: rq.name.clone(), host: rq.host, - hold_time: rq.hold_time, - idle_hold_time: rq.idle_hold_time, - delay_open: rq.delay_open, - connect_retry: rq.connect_retry, - keepalive: rq.keepalive, - resolution: rq.resolution, - group: rq.group.clone(), - passive: rq.passive, - md5_auth_key: rq.md5_auth_key, - multi_exit_discriminator: rq.multi_exit_discriminator, - communities: rq.communities, - local_pref: rq.local_pref, - enforce_first_as: rq.enforce_first_as, - // Derive enablement from whether the AF config is present - ipv4_enabled: rq.ipv4_unicast.is_some(), - ipv6_enabled: rq.ipv6_unicast.is_some(), - allow_import4, - allow_export4, - allow_import6, - allow_export6, - nexthop4, - nexthop6, - vlan_id: rq.vlan_id, + parameters: BgpNeighborParameters { + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + passive: rq.parameters.passive, + md5_auth_key: rq.parameters.md5_auth_key, + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities, + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + allow_import4, + allow_import6, + allow_export4, + allow_export6, + ipv4_enabled: rq.parameters.ipv4_unicast.is_some(), + ipv6_enabled: rq.parameters.ipv6_unicast.is_some(), + nexthop4, + nexthop6, + vlan_id: rq.parameters.vlan_id, + }, })?; if start_session { @@ -1399,17 +2045,101 @@ pub(crate) mod helpers { Ok(()) } - pub(crate) async fn reset_neighbor( + pub(crate) fn add_unnumbered_neighbor( ctx: Arc, - rq: NeighborResetRequest, - ) -> Result { - bgp_log!(ctx.log, info, "clear {rq}"); + rq: UnnumberedNeighbor, + ensure: bool, + ) -> Result<(), Error> { + let log = &ctx.log; + bgp_log!(log, info, "add unnumbered neighbor {}", rq.interface; + "params" => format!("{rq:#?}") + ); - let session = get_router!(ctx, rq.asn)? - .get_session(rq.addr) - .ok_or(Error::NotFound("session for bgp peer not found".into()))?; + let info = SessionInfo::from(&rq.parameters); + + // Extract per-AF policies and nexthop for database storage + let (allow_import4, allow_export4, nexthop4) = + match &rq.parameters.ipv4_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), + None => ( + ImportExportPolicy4::NoFiltering, + ImportExportPolicy4::NoFiltering, + None, + ), + }; - match rq.op { + let (allow_import6, allow_export6, nexthop6) = + match &rq.parameters.ipv6_unicast { + Some(cfg) => ( + cfg.import_policy.clone(), + cfg.export_policy.clone(), + cfg.nexthop, + ), + None => ( + ImportExportPolicy6::NoFiltering, + ImportExportPolicy6::NoFiltering, + None, + ), + }; + + ctx.db + .add_unnumbered_bgp_neighbor(rdb::BgpUnnumberedNeighborInfo { + asn: rq.asn, + name: rq.name.clone(), + group: rq.group.clone(), + interface: rq.interface.clone(), + router_lifetime: rq.act_as_a_default_ipv6_router, + parameters: BgpNeighborParameters { + remote_asn: rq.parameters.remote_asn, + min_ttl: rq.parameters.min_ttl, + hold_time: rq.parameters.hold_time, + idle_hold_time: rq.parameters.idle_hold_time, + delay_open: rq.parameters.delay_open, + connect_retry: rq.parameters.connect_retry, + keepalive: rq.parameters.keepalive, + resolution: rq.parameters.resolution, + passive: rq.parameters.passive, + md5_auth_key: rq.parameters.md5_auth_key.clone(), + multi_exit_discriminator: rq + .parameters + .multi_exit_discriminator, + communities: rq.parameters.communities.clone(), + local_pref: rq.parameters.local_pref, + enforce_first_as: rq.parameters.enforce_first_as, + allow_import4, + allow_import6, + allow_export4, + allow_export6, + ipv4_enabled: rq.parameters.ipv4_unicast.is_some(), + ipv6_enabled: rq.parameters.ipv6_unicast.is_some(), + nexthop4, + nexthop6, + vlan_id: rq.parameters.vlan_id, + }, + })?; + + ctx.bgp.unnumbered_manager.add_neighbor( + rq.asn, + &rq.interface, + info, + rq.clone(), + ensure, + )?; + + Ok(()) + } + + /// Central session reset logic - operates directly on a session. + /// Sends the appropriate FSM events based on the reset operation type. + fn reset_session( + session: &Arc>, + op: NeighborResetOp, + ) -> Result<(), Error> { + match op { NeighborResetOp::Hard => { session .event_tx @@ -1483,7 +2213,42 @@ pub(crate) mod helpers { } } } + Ok(()) + } + + pub(crate) async fn reset_neighbor( + ctx: Arc, + rq: NeighborResetRequest, + ) -> Result { + bgp_log!(ctx.log, info, "clear {rq}"); + + let session = get_router!(ctx, rq.asn)? + .get_session(rq.addr) + .ok_or(Error::NotFound("session for bgp peer not found".into()))?; + reset_session(&session, rq.op)?; + Ok(HttpResponseUpdatedNoContent()) + } + + pub(crate) async fn reset_unnumbered_neighbor( + ctx: Arc, + asn: u32, + interface: &str, + op: NeighborResetOp, + ) -> Result { + bgp_log!(ctx.log, info, "clear unnumbered neighbor {interface}, asn {asn}"; + "op" => format!("{op:?}") + ); + + let session = ctx + .bgp + .unnumbered_manager + .get_neighbor_session(asn, interface)? + .ok_or(Error::NotFound( + "session for unnumbered neighbor not found".into(), + ))?; + + reset_session(&session, op)?; Ok(HttpResponseUpdatedNoContent()) } @@ -1503,7 +2268,7 @@ pub(crate) mod helpers { cfg, ctx.log.clone(), db.clone(), - ctx.bgp.addr_to_session.clone(), + ctx.bgp.peer_to_session.clone(), )); router.run(); @@ -1637,6 +2402,101 @@ pub(crate) mod helpers { } Ok(HttpResponseDeleted()) } + + /// Calculate exported routes for a single session. + /// Returns None if the peer is not Established or has no routes to export. + pub(crate) fn get_exported( + session: &SessionRunner, + orig4: &[Prefix4], + orig6: &[Prefix6], + process_ipv4: bool, + process_ipv6: bool, + ) -> Option<(PeerId, Vec)> { + // Only process Established peers + if session.state() != FsmStateKind::Established { + return None; + } + + // Use PeerId as the key (supports both numbered and unnumbered peers) + let peer_key = session.neighbor.peer.clone(); + + // Get the primary connection to check negotiated capabilities + let primary = session.primary_connection()?; + + // Extract negotiated AFI/SAFI states from the connection + let (ipv4_negotiated, ipv6_negotiated) = match primary { + ConnectionKind::Full(ref peer_conn) => ( + peer_conn.ipv4_unicast.negotiated(), + peer_conn.ipv6_unicast.negotiated(), + ), + ConnectionKind::Partial(_) => return None, + }; + + // Get session configuration for export policies + let session_info = lock!(session.session); + let mut peer_exported_routes: Vec = Vec::new(); + + // Process IPv4 routes if requested and negotiated + if process_ipv4 + && ipv4_negotiated + && let Some(ref ipv4_config) = session_info.ipv4_unicast + { + let mut v4_routes: Vec = + orig4.iter().map(|p| rdb::Prefix::from(*p)).collect(); + + // Apply export policy + match &ipv4_config.export_policy { + ImportExportPolicy4::NoFiltering => { + peer_exported_routes.extend(v4_routes); + } + ImportExportPolicy4::Allow(allowed) => { + v4_routes.retain(|p| { + if let Prefix::V4(p4) = p { + allowed.contains(p4) + } else { + false + } + }); + peer_exported_routes.extend(v4_routes); + } + } + } + + // Process IPv6 routes if requested and negotiated + if process_ipv6 + && ipv6_negotiated + && let Some(ref ipv6_config) = session_info.ipv6_unicast + { + let mut v6_routes: Vec = + orig6.iter().map(|p| rdb::Prefix::from(*p)).collect(); + + // Apply export policy + match &ipv6_config.export_policy { + ImportExportPolicy6::NoFiltering => { + peer_exported_routes.extend(v6_routes); + } + ImportExportPolicy6::Allow(allowed) => { + v6_routes.retain(|p| { + if let Prefix::V6(p6) = p { + allowed.contains(p6) + } else { + false + } + }); + peer_exported_routes.extend(v6_routes); + } + } + } + + // Only return if we have exported routes + if peer_exported_routes.is_empty() { + return None; + } + + // Stable output order for clients + peer_exported_routes.sort(); + Some((peer_key, peer_exported_routes)) + } } #[cfg(test)] @@ -1645,9 +2505,9 @@ mod tests { use crate::{ admin::HandlerContext, bfd_admin::BfdContext, bgp_admin::BgpContext, }; - use bgp::params::{ApplyRequestV1, BgpPeerConfigV1}; + use bgp::params::{ApplyRequestV1, BgpPeerConfigV1, BgpPeerParametersV1}; use mg_common::stats::MgLowerStats; - use rdb::Db; + use rdb::test::get_test_db; #[cfg(feature = "mg-lower")] use std::net::Ipv6Addr; use std::{ @@ -1670,17 +2530,20 @@ mod tests { } create_dir_all(&tmpdir).unwrap(); println!("tmpdir is {tmpdir}"); - let dbdir = format!("{tmpdir}/test.db"); let log = mg_common::log::init_file_logger("apply_remove_entire_group.log"); + let db = get_test_db("apply_remove_entire_group", log.clone()).unwrap(); let ctx = Arc::new(HandlerContext { #[cfg(feature = "mg-lower")] tep: Ipv6Addr::UNSPECIFIED, - bgp: BgpContext::new(Arc::new(Mutex::new(BTreeMap::new()))), + bgp: BgpContext::new( + Arc::new(Mutex::new(BTreeMap::new())), + log.clone(), + ), bfd: BfdContext::new(log.clone()), log: log.clone(), - db: Db::new(dbdir.as_str(), log.clone()).unwrap(), + db: (*db).clone(), mg_lower_stats: Arc::new(MgLowerStats::default()), stats_server_running: Mutex::new(false), oximeter_port: 0, @@ -1692,23 +2555,25 @@ mod tests { vec![BgpPeerConfigV1 { host: SocketAddr::new("203.0.113.1".parse().unwrap(), 179), name: String::from("bob"), - hold_time: 3, - idle_hold_time: 1, - delay_open: 1, - connect_retry: 1, - keepalive: 1, - resolution: 1, - passive: false, - remote_asn: None, - min_ttl: None, - md5_auth_key: None, - multi_exit_discriminator: None, - communities: Vec::default(), - local_pref: None, - enforce_first_as: false, - allow_import: rdb::ImportExportPolicyV1::NoFiltering, - allow_export: rdb::ImportExportPolicyV1::NoFiltering, - vlan_id: None, + parameters: BgpPeerParametersV1 { + hold_time: 3, + idle_hold_time: 1, + delay_open: 1, + connect_retry: 1, + keepalive: 1, + resolution: 1, + passive: false, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::default(), + local_pref: None, + enforce_first_as: false, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::NoFiltering, + vlan_id: None, + }, }], ); peers.insert( @@ -1716,23 +2581,25 @@ mod tests { vec![BgpPeerConfigV1 { host: SocketAddr::new("203.0.113.2".parse().unwrap(), 179), name: String::from("alice"), - hold_time: 3, - idle_hold_time: 1, - delay_open: 1, - connect_retry: 1, - keepalive: 1, - resolution: 1, - passive: false, - remote_asn: None, - min_ttl: None, - md5_auth_key: None, - multi_exit_discriminator: None, - communities: Vec::default(), - local_pref: None, - enforce_first_as: false, - allow_import: rdb::ImportExportPolicyV1::NoFiltering, - allow_export: rdb::ImportExportPolicyV1::NoFiltering, - vlan_id: None, + parameters: BgpPeerParametersV1 { + hold_time: 3, + idle_hold_time: 1, + delay_open: 1, + connect_retry: 1, + keepalive: 1, + resolution: 1, + passive: false, + remote_asn: None, + min_ttl: None, + md5_auth_key: None, + multi_exit_discriminator: None, + communities: Vec::default(), + local_pref: None, + enforce_first_as: false, + allow_import: rdb::ImportExportPolicyV1::NoFiltering, + allow_export: rdb::ImportExportPolicyV1::NoFiltering, + vlan_id: None, + }, }], ); diff --git a/mgd/src/error.rs b/mgd/src/error.rs index 4637b57b..e184c980 100644 --- a/mgd/src/error.rs +++ b/mgd/src/error.rs @@ -2,6 +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::unnumbered_manager::{AddNeighborError, ResolveNeighborError}; use dropshot::{ClientErrorStatusCode, HttpError}; #[derive(thiserror::Error, Debug)] @@ -18,6 +19,12 @@ pub enum Error { #[error("bgp error: {0}")] Bgp(#[from] bgp::error::Error), + #[error("error adding an unnumbered error: {0}")] + AddUnnumberedNeighbor(#[from] AddNeighborError), + + #[error("error resolving a neighbor: {0}")] + ResolveNeighbor(#[from] ResolveNeighborError), + #[error("internal communication error: {0}")] InternalCommunication(String), @@ -43,6 +50,35 @@ impl From for HttpError { } _ => Self::for_internal_error(value.to_string()), }, + Error::AddUnnumberedNeighbor(ref err) => match err { + AddNeighborError::Resolve(e) => match e { + ResolveNeighborError::NoSuchInterface + | ResolveNeighborError::NotIpv6Interface => { + Self::for_client_error_with_status( + Some(err.to_string()), + ClientErrorStatusCode::BAD_REQUEST, + ) + } + ResolveNeighborError::System(e) => { + Self::for_internal_error(e.to_string()) + } + }, + AddNeighborError::NdpManager(e) => { + Self::for_internal_error(e.to_string()) + } + }, + Error::ResolveNeighbor(ref err) => match err { + ResolveNeighborError::NoSuchInterface + | ResolveNeighborError::NotIpv6Interface => { + Self::for_client_error_with_status( + Some(err.to_string()), + ClientErrorStatusCode::BAD_REQUEST, + ) + } + ResolveNeighborError::System(e) => { + Self::for_internal_error(e.to_string()) + } + }, Error::InternalCommunication(_) => { Self::for_internal_error(value.to_string()) } diff --git a/mgd/src/main.rs b/mgd/src/main.rs index 78c5099e..32ffd66f 100644 --- a/mgd/src/main.rs +++ b/mgd/src/main.rs @@ -7,6 +7,7 @@ use crate::bfd_admin::BfdContext; use crate::bgp_admin::BgpContext; use crate::log::dlog; use bgp::connection_tcp::{BgpConnectionTcp, BgpListenerTcp}; +use bgp::params::BgpPeerParametersV1; use clap::{Parser, Subcommand}; use mg_common::cli::oxide_cli_style; use mg_common::lock; @@ -36,6 +37,7 @@ mod rib_admin; mod signal; mod smf; mod static_admin; +mod unnumbered_manager; mod validation; #[derive(Parser, Debug)] @@ -109,9 +111,9 @@ async fn run(args: RunArgs) { .await .expect("set up refresh signal handler"); - let bgp = init_bgp(&args, &log); let db = rdb::Db::new(&format!("{}/rdb", args.data_dir), log.clone()) .expect("open datastore file"); + let bgp = init_bgp(&args, &log); let tep_ula = get_tunnel_endpoint_ula(&db); let bfd = BfdContext::new(log.clone()); @@ -252,13 +254,18 @@ fn detect_switch_slot( } fn init_bgp(args: &RunArgs, log: &Logger) -> BgpContext { - let addr_to_session = Arc::new(Mutex::new(BTreeMap::new())); + let peer_to_session = Arc::new(Mutex::new(BTreeMap::new())); + + // Create BgpContext first to get access to unnumbered_manager + let bgp_context = BgpContext::new(peer_to_session.clone(), log.clone()); + if !args.no_bgp_dispatcher { let bgp_dispatcher = bgp::dispatcher::Dispatcher::::new( - addr_to_session.clone(), + peer_to_session.clone(), "[::]:179".into(), log.clone(), + Some(bgp_context.unnumbered_manager.clone()), // Enable link-local connection routing ); let listener_str = @@ -269,7 +276,8 @@ fn init_bgp(args: &RunArgs, log: &Logger) -> BgpContext { .spawn(move || bgp_dispatcher.run::()) .expect("failed to start {listener_str}"); } - BgpContext::new(addr_to_session) + + bgp_context } fn start_bgp_routers( @@ -299,33 +307,38 @@ fn start_bgp_routers( context.clone(), bgp::params::NeighborV1 { asn: nbr.asn, - remote_asn: nbr.remote_asn, - min_ttl: nbr.min_ttl, + group: nbr.group.clone(), name: nbr.name.clone(), host: nbr.host, - hold_time: nbr.hold_time, - idle_hold_time: nbr.idle_hold_time, - delay_open: nbr.delay_open, - connect_retry: nbr.connect_retry, - keepalive: nbr.keepalive, - resolution: nbr.resolution, - group: nbr.group.clone(), - passive: nbr.passive, - md5_auth_key: nbr.md5_auth_key.clone(), - multi_exit_discriminator: nbr.multi_exit_discriminator, - communities: nbr.communities.clone(), - local_pref: nbr.local_pref, - enforce_first_as: nbr.enforce_first_as, - // Combine per-AF policies into legacy format for API compatibility - allow_import: rdb::ImportExportPolicyV1::from_per_af_policies( - &nbr.allow_import4, - &nbr.allow_import6, - ), - allow_export: rdb::ImportExportPolicyV1::from_per_af_policies( - &nbr.allow_export4, - &nbr.allow_export6, - ), - vlan_id: nbr.vlan_id, + parameters: BgpPeerParametersV1 { + remote_asn: nbr.parameters.remote_asn, + min_ttl: nbr.parameters.min_ttl, + hold_time: nbr.parameters.hold_time, + idle_hold_time: nbr.parameters.idle_hold_time, + delay_open: nbr.parameters.delay_open, + connect_retry: nbr.parameters.connect_retry, + keepalive: nbr.parameters.keepalive, + resolution: nbr.parameters.resolution, + passive: nbr.parameters.passive, + md5_auth_key: nbr.parameters.md5_auth_key.clone(), + multi_exit_discriminator: nbr + .parameters + .multi_exit_discriminator, + communities: nbr.parameters.communities.clone(), + local_pref: nbr.parameters.local_pref, + enforce_first_as: nbr.parameters.enforce_first_as, + allow_import: + rdb::ImportExportPolicyV1::from_per_af_policies( + &nbr.parameters.allow_import4, + &nbr.parameters.allow_import6, + ), + allow_export: + rdb::ImportExportPolicyV1::from_per_af_policies( + &nbr.parameters.allow_export4, + &nbr.parameters.allow_export6, + ), + vlan_id: nbr.parameters.vlan_id, + }, }, true, ) diff --git a/mgd/src/oxstats.rs b/mgd/src/oxstats.rs index c2c72e92..3f725173 100644 --- a/mgd/src/oxstats.rs +++ b/mgd/src/oxstats.rs @@ -6,6 +6,7 @@ use crate::admin::HandlerContext; use crate::bfd_admin::BfdContext; use crate::bgp_admin::BgpContext; use crate::log::olog; +use bgp::session::PeerId; use chrono::{DateTime, Utc}; use mg_common::lock; use mg_common::nexus::{local_underlay_address, run_oximeter}; @@ -283,9 +284,12 @@ impl Stats { for (asn, r) in &*routers { let mut session_counters = BTreeMap::new(); let sessions = lock!(r.sessions); - for (addr, session) in &*sessions { - session_counters.insert(*addr, session.counters.clone()); - session_count += 1; + for (key, session) in &*sessions { + // Only include IP-based sessions in metrics (unnumbered sessions use interface names) + if let PeerId::Ip(addr) = key { + session_counters.insert(*addr, session.counters.clone()); + session_count += 1; + } } router_counters.insert(*asn, session_counters); } diff --git a/mgd/src/rib_admin.rs b/mgd/src/rib_admin.rs index 3abedc09..52cec9ef 100644 --- a/mgd/src/rib_admin.rs +++ b/mgd/src/rib_admin.rs @@ -8,15 +8,16 @@ use dropshot::{ RequestContext, TypedBody, }; use mg_api::{ - BestpathFanoutRequest, BestpathFanoutResponse, Rib, RibQuery, + BestpathFanoutRequest, BestpathFanoutResponse, Rib, RibQuery, RibV1, filter_rib_by_protocol, }; use std::sync::Arc; +// Original version (VERSION_IPV6_BASIC..VERSION_UNNUMBERED): BgpPathProperties.peer is IpAddr pub async fn get_rib_imported( ctx: RequestContext>, query: Query, -) -> Result, HttpError> { +) -> Result, HttpError> { let ctx = ctx.context(); let query = query.into_inner(); let imported = ctx.db.full_rib(query.address_family); @@ -27,6 +28,29 @@ pub async fn get_rib_imported( pub async fn get_rib_selected( ctx: RequestContext>, query: Query, +) -> Result, HttpError> { + let ctx = ctx.context(); + let query = query.into_inner(); + let selected = ctx.db.loc_rib(query.address_family); + let filtered = filter_rib_by_protocol(selected, query.protocol); + Ok(HttpResponseOk(filtered.into())) +} + +// VERSION_UNNUMBERED+ (BgpPathProperties.peer is PeerId enum) +pub async fn get_rib_imported_v2( + ctx: RequestContext>, + query: Query, +) -> Result, HttpError> { + let ctx = ctx.context(); + let query = query.into_inner(); + let imported = ctx.db.full_rib(query.address_family); + let filtered = filter_rib_by_protocol(imported, query.protocol); + Ok(HttpResponseOk(filtered.into())) +} + +pub async fn get_rib_selected_v2( + ctx: RequestContext>, + query: Query, ) -> Result, HttpError> { let ctx = ctx.context(); let query = query.into_inner(); diff --git a/mgd/src/unnumbered_manager.rs b/mgd/src/unnumbered_manager.rs new file mode 100644 index 00000000..9c4883c2 --- /dev/null +++ b/mgd/src/unnumbered_manager.rs @@ -0,0 +1,476 @@ +// 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 bgp::{ + connection_tcp::BgpConnectionTcp, + params::UnnumberedNeighbor, + router::Router, + session::{SessionInfo, SessionRunner}, +}; +use mg_common::lock; +use ndp::{Ipv6NetworkInterface, NdpManager, NewInterfaceNdpManagerError}; +use network_interface::{NetworkInterface, NetworkInterfaceConfig}; +use slog::{Logger, error, o, warn}; +use std::{ + collections::{BTreeMap, HashMap}, + net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV6}, + sync::{Arc, Mutex, mpsc::channel}, +}; + +pub const MOD_UNNUMBERED_MANAGER: &str = "unnumbered manager"; + +pub struct UnnumberedManagerNdp { + routers: Arc>>>>, + ndp_mgr: Arc, + /// Maps scope_id (interface index) to interface name for Dispatcher routing + interface_scope_map: Mutex>, + log: Logger, +} + +#[derive(Debug, thiserror::Error)] +pub enum ResolveNeighborError { + #[error("No such interface")] + NoSuchInterface, + #[error("Interface has no IPv6 link local address")] + NotIpv6Interface, + #[error("Could not get system interfaces: {0}")] + System(#[from] network_interface::Error), +} + +#[derive(Debug, thiserror::Error)] +pub enum AddNeighborError { + #[error("resolve neighbor error: {0}")] + Resolve(#[from] ResolveNeighborError), + + #[error("add interface error: {0}")] + NdpManager(#[from] NewInterfaceNdpManagerError), +} + +impl UnnumberedManagerNdp { + pub fn new( + routers: Arc>>>>, + log: Logger, + ) -> Arc { + let log = log.new(o!( + "component" => crate::COMPONENT_MGD, + "unit" => crate::UNIT_DAEMON, + "module" => MOD_UNNUMBERED_MANAGER, + )); + + Arc::new(Self { + routers, + interface_scope_map: Mutex::new(HashMap::default()), + ndp_mgr: NdpManager::new(log.clone()), + log, + }) + } + + pub fn add_neighbor( + self: &Arc, + asn: u32, + interface: impl AsRef, + info: SessionInfo, + nbr: UnnumberedNeighbor, + ensure: bool, + ) -> Result<(), AddNeighborError> { + let interface_str = interface.as_ref(); + + // Try to get the interface - this can fail if the interface doesn't exist + // or isn't configured properly + let ifx_result = Self::get_interface(interface_str, &self.log); + + // Check if we're in ensure mode and updating an existing session + let router_guard = lock!(self.routers); + let updating_existing = if ensure { + if let Some(router) = router_guard.get(&asn) { + router.get_session(interface_str).is_some() + } else { + false + } + } else { + false + }; + + // If we're updating an existing session and the interface resolution failed, + // we can proceed without the interface info - the session already has it + let ifx: Option = match ( + ifx_result, + updating_existing, + ) { + (Ok(ifx), _) => Some(ifx), + (Err(e), true) => { + // Updating existing session but interface not found - that's OK, + // we just skip interface setup and update the session config + slog::info!( + self.log, + "updating unnumbered session for interface that is not currently available"; + "interface" => interface_str, + "error" => e.to_string(), + ); + None + } + (Err(e), false) => { + // Not updating or interface is required for new session + return Err(AddNeighborError::Resolve(e)); + } + }; + + // Only add/update interface in NDP manager if we successfully resolved it + if let Some(ref ifx) = ifx { + // Add interface to NDP manager for peer discovery + self.ndp_mgr + .add_interface(ifx.clone(), nbr.act_as_a_default_ipv6_router)?; + + // Store scope_id mapping for Dispatcher routing + lock!(self.interface_scope_map).insert(ifx.index, ifx.name.clone()); + } + + // Create or update unnumbered session + if let Some(router) = router_guard.get(&asn) { + let (event_tx, event_rx) = channel(); + + // Use the resolved interface index if available, otherwise use 0 for updates + let scope_id = ifx.as_ref().map(|i| i.index).unwrap_or(0); + let placeholder_host = + SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, scope_id); + + if let Err(e) = router.ensure_unnumbered_session( + interface_str.to_string(), + nbr.to_peer_config(placeholder_host), + None, + event_tx.clone(), + event_rx, + info, + self.clone(), // Pass unnumbered manager for active NDP queries + ) { + error!( + self.log, + "error creating/updating unnumbered session"; + "error" => e.to_string(), + "interface" => interface_str, + ); + return Err(AddNeighborError::Resolve( + ResolveNeighborError::NoSuchInterface, + )); + } + } else { + warn!( + self.log, + "session configured for asn {}, but no router is running", asn + ); + } + + Ok(()) + } + + pub fn remove_neighbor( + self: &Arc, + _asn: u32, + interface: impl AsRef, + ) -> Result<(), ResolveNeighborError> { + let interface_str = interface.as_ref(); + + if let Ok(ifx) = Self::get_interface(interface_str, &self.log) { + self.ndp_mgr.remove_interface(ifx); + } + + // Clean up scope mapping by searching for interface name. + // This works whether or not the interface still exists in the system. + let mut scope_map = lock!(self.interface_scope_map); + if let Some((&scope_id, _)) = scope_map + .iter() + .find(|(_, name)| name.as_str() == interface_str) + { + scope_map.remove(&scope_id); + } + + Ok(()) + } + + pub fn get_neighbor_session( + self: &Arc, + asn: u32, + interface: impl AsRef, + ) -> Result< + Option>>, + ResolveNeighborError, + > { + if let Some(rtr) = lock!(self.routers).get(&asn) + && let Some(session) = rtr.get_session(interface.as_ref()) + { + return Ok(Some(session)); + }; + Ok(None) + } + + // ========================================================================= + // NDP Query Interface + // ========================================================================= + // These methods provide the query interface for SessionRunner and Dispatcher + // to access current NDP state without triggering session management. + // + // Currently, SessionRunner uses neighbor_cell for passive updates (updated by + // the run loop when NDP discovers peers). These methods are available for + // future active query scenarios. + + /// Get the currently discovered neighbor for an interface. + /// + /// Returns the peer's link-local SocketAddr (with scope_id set) if a neighbor + /// has been discovered via NDP, or None if no neighbor is present. + /// + /// This is used by SessionRunner to actively query for peer addresses + /// when attempting connections on unnumbered interfaces. + /// + /// # Arguments + /// * `interface` - The interface name (e.g., "eth0") + /// + /// # Returns + /// * `Ok(Some(SocketAddr))` - Neighbor discovered at this address + /// * `Ok(None)` - No neighbor discovered yet + /// * `Err(ResolveNeighborError)` - Interface not found or not IPv6 + pub fn get_neighbor_for_interface( + &self, + interface: impl AsRef, + ) -> Result, ResolveNeighborError> { + let ifx = Self::get_interface(interface.as_ref(), &self.log)?; + + if let Some(peer_addr) = self.ndp_mgr.get_peer(&ifx) { + // Construct SocketAddr with scope_id from interface index + let socket_addr = SocketAddr::V6(SocketAddrV6::new( + peer_addr, 179, // BGP port + 0, // flowinfo + ifx.index, + )); + Ok(Some(socket_addr)) + } else { + Ok(None) + } + } + + /// Get the interface name for a given IPv6 scope_id. + /// + /// This is used by Dispatcher to route incoming link-local connections + /// to the correct unnumbered session based on the scope_id in the peer + /// address. + /// + /// # Arguments + /// * `scope_id` - The IPv6 scope_id (interface index) + /// + /// # Returns + /// * `Some(interface_name)` - Interface found for this scope_id + /// * `None` - No interface registered with this scope_id + pub fn get_interface_for_scope(&self, scope_id: u32) -> Option { + lock!(self.interface_scope_map).get(&scope_id).cloned() + } + + /// Validate that a peer address matches the discovered neighbor for an interface. + /// + /// This is used by SessionRunner to validate incoming connections on + /// unnumbered interfaces, ensuring the connection is from the expected + /// NDP-discovered neighbor. + /// + /// # Arguments + /// * `interface` - The interface name + /// * `peer` - The peer address to validate + /// + /// # Returns + /// * `true` - Peer matches the discovered neighbor for this interface + /// * `false` - Peer does not match or no neighbor discovered + /// + /// Note: Currently unused as validation happens via Dispatcher routing. + /// Available for future explicit validation scenarios. + #[allow(dead_code)] + pub fn validate_peer_for_interface( + &self, + interface: impl AsRef, + peer: SocketAddr, + ) -> bool { + // Get discovered neighbor for interface + if let Ok(Some(discovered)) = self.get_neighbor_for_interface(interface) + { + // Compare IP addresses (ignore port/flowinfo/scope_id differences) + discovered.ip() == peer.ip() + } else { + false + } + } + + fn get_interface( + name: &str, + log: &Logger, + ) -> Result { + let candidates: Vec<_> = NetworkInterface::show()? + .into_iter() + .filter(|x| x.name == name) + .collect(); + + if candidates.is_empty() { + return Err(ResolveNeighborError::NoSuchInterface); + } + + let mut local: Vec<_> = candidates + .into_iter() + .filter_map(|x| x.addr.map(|addr| (addr, x.index))) + .filter_map(|(addr, idx)| match addr.ip() { + IpAddr::V6(ip) if ip.is_unicast_link_local() => Some((ip, idx)), + _ => None, + }) + .collect(); + + let Some((addr, index)) = local.pop() else { + return Err(ResolveNeighborError::NotIpv6Interface); + }; + + if !local.is_empty() { + warn!( + log, + "more than 1 link local address for interface"; + "using" => addr.to_string(), + "also found" => local + .into_iter() + .map(|x| x.0.to_string()) + .collect::>() + .join(","), + ); + } + + Ok(Ipv6NetworkInterface { + name: name.to_owned(), + ip: addr, + index, + }) + } +} + +// ========================================================================= +// UnnumberedManager Trait Implementation +// ========================================================================= +// Provides the interface for Dispatcher to query interface mappings +impl bgp::unnumbered::UnnumberedManager for UnnumberedManagerNdp { + fn get_interface_for_scope(&self, scope_id: u32) -> Option { + // Delegate to the existing implementation + Self::get_interface_for_scope(self, scope_id) + } + + fn get_neighbor_for_interface( + &self, + interface: &str, + ) -> Result, Box> { + // Delegate to the existing implementation + Self::get_neighbor_for_interface(self, interface) + .map_err(|e| Box::new(e) as Box) + } +} + +impl UnnumberedManagerNdp { + /// List all NDP-managed interfaces with detailed discovery state. + /// + /// Returns a list of interfaces managed by this unnumbered manager, + /// including full peer advertisement information with timestamps. + /// + /// The caller should filter by ASN based on which neighbors are configured + /// for unnumbered BGP sessions. + pub fn list_ndp_interfaces(&self) -> Vec { + // Get detailed interface information from NDP manager + let detailed_interfaces = self.ndp_mgr.list_interfaces_detailed(); + + // Filter to only interfaces we're managing for BGP unnumbered + let scope_map = lock!(self.interface_scope_map); + + detailed_interfaces + .into_iter() + .filter_map(|info| { + // Only include interfaces in our scope map + if !scope_map.contains_key(&info.interface.index) { + return None; + } + + let peer_state = + info.discovered_peer.as_ref().map(|detail| NdpPeerState { + address: detail.address, + first_seen: detail.first_seen, + when: detail.when, + router_lifetime: detail.router_lifetime, + reachable_time: detail.reachable_time, + retrans_timer: detail.retrans_timer, + expired: detail.expired, + }); + + Some(ManagedInterfaceInfo { + interface: info.interface.name, + local_address: info.interface.ip, + scope_id: info.interface.index, + peer_state, + }) + }) + .collect() + } + + /// Get detailed NDP state for a specific interface. + /// + /// Returns detailed information about peer discovery including timestamps, + /// RA parameters, and expiry status. + /// + /// Returns None if the interface is not managed by NDP. + pub fn get_ndp_interface_detail( + &self, + interface_name: &str, + ) -> Result, ResolveNeighborError> { + let ifx = Self::get_interface(interface_name, &self.log)?; + + // Check if we're managing this interface + if !lock!(self.interface_scope_map).contains_key(&ifx.index) { + return Ok(None); + } + + // Get detailed peer information from NDP manager + let peer_state = + self.ndp_mgr + .get_peer_detail(&ifx) + .map(|detail| NdpPeerState { + address: detail.address, + first_seen: detail.first_seen, + when: detail.when, + router_lifetime: detail.router_lifetime, + reachable_time: detail.reachable_time, + retrans_timer: detail.retrans_timer, + expired: detail.expired, + }); + + Ok(Some(InterfaceDetail { + local_address: ifx.ip, + scope_id: ifx.index, + peer_state, + })) + } +} + +/// Information about a managed interface with NDP state +#[derive(Debug, Clone)] +pub struct ManagedInterfaceInfo { + pub interface: String, + pub local_address: Ipv6Addr, + pub scope_id: u32, + pub peer_state: Option, +} + +/// Detailed NDP state for a specific interface +#[derive(Debug, Clone)] +pub struct InterfaceDetail { + pub local_address: Ipv6Addr, + pub scope_id: u32, + pub peer_state: Option, +} + +/// Detailed NDP peer state with full RA information +#[derive(Debug, Clone)] +pub struct NdpPeerState { + pub address: Ipv6Addr, + pub first_seen: std::time::Instant, + pub when: std::time::Instant, + pub router_lifetime: u16, + pub reachable_time: u32, + pub retrans_timer: u32, + pub expired: bool, +} diff --git a/ndp/Cargo.toml b/ndp/Cargo.toml new file mode 100644 index 00000000..d4224cf5 --- /dev/null +++ b/ndp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "ndp" +version = "0.1.0" +edition = "2024" + +[dependencies] +ispf.workspace = true +serde.workspace = true +oxnet.workspace = true +socket2.workspace = true +slog.workspace = true +internet-checksum.workspace = true +network-interface.workspace = true +thiserror.workspace = true +mg-common.workspace = true +libc.workspace = true diff --git a/ndp/src/lib.rs b/ndp/src/lib.rs new file mode 100644 index 00000000..d39396ea --- /dev/null +++ b/ndp/src/lib.rs @@ -0,0 +1,13 @@ +//! Neighbor discovery protocol support crate + +// 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/ +// +// Copyright 2026 Oxide Computer Company + +mod manager; +mod packet; +mod util; + +pub use manager::*; diff --git a/ndp/src/manager.rs b/ndp/src/manager.rs new file mode 100644 index 00000000..19a9a484 --- /dev/null +++ b/ndp/src/manager.rs @@ -0,0 +1,383 @@ +// 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/ +// +// Copyright 2026 Oxide Computer Company + +use crate::packet::{Icmp6RouterAdvertisement, Icmp6RouterSolicitation}; +use crate::util::{ + DropSleep, ListeningSocketError, ReceivedAdvertisement, create_socket, + send_ra, send_rs, +}; +use mg_common::thread::ManagedThread; +use mg_common::{lock, read_lock, write_lock}; +use slog::{Logger, error}; +use socket2::Socket; +use std::mem::MaybeUninit; +use std::net::Ipv6Addr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread::{Builder, sleep}; +use std::time::{Duration, Instant}; + +/// The `NdpManager` performs router discovery for a provided set of interfaces. +/// +/// Use `add_interface` and `remove_interface` to manage discovery interfaces and +/// use `get_peer` to determine if a router peer has been discovered for a given +/// interface. +#[derive(Debug)] +pub struct NdpManager { + /// Individual interface-level NDP managers. + interfaces: RwLock>>, + log: Logger, +} + +impl NdpManager { + /// Create a new NDP manager. + pub fn new(log: Logger) -> Arc { + Arc::new(Self { + interfaces: RwLock::new(Vec::default()), + log, + }) + } + + /// Add an interface to the NDP manager. Discovery starts immediately. + pub fn add_interface( + &self, + ifx: Ipv6NetworkInterface, + router_lifetime: u16, + ) -> Result<(), NewInterfaceNdpManagerError> { + write_lock!(self.interfaces).push(InterfaceNdpManager::new( + ifx, + router_lifetime, + self.log.clone(), + )?); + Ok(()) + } + + /// Remove an interface from the NDP manager. Discovery is stopped when + /// the interface is removed. + pub fn remove_interface(&self, ifx: Ipv6NetworkInterface) -> bool { + let mut ifxs_guard = write_lock!(self.interfaces); + let Some(pos) = ifxs_guard.iter().position(|x| x.inner.ifx == ifx) + else { + return false; + }; + + ifxs_guard.remove(pos); + true + } + + /// Get a router peer, if any, that has been discovered for the given interface. + pub fn get_peer(&self, ifx: &Ipv6NetworkInterface) -> Option { + let ifxs_guard = read_lock!(self.interfaces); + let interface = ifxs_guard.iter().find(|x| &x.inner.ifx == ifx)?; + let nbr_guard = lock!(interface.inner.neighbor_router); + let neighbor_router = nbr_guard.as_ref()?; + + if neighbor_router.expired() { + None + } else { + Some(neighbor_router.sender) + } + } + + /// Get detailed information about a discovered peer for a specific interface. + /// + /// Returns full advertisement details including timestamps and RA parameters, + /// even if the peer has expired. Returns None only if no peer was ever discovered. + pub fn get_peer_detail( + &self, + ifx: &Ipv6NetworkInterface, + ) -> Option { + let ifxs_guard = read_lock!(self.interfaces); + let interface = ifxs_guard.iter().find(|x| &x.inner.ifx == ifx)?; + let nbr_guard = lock!(interface.inner.neighbor_router); + let neighbor_router = nbr_guard.as_ref()?; + + Some(PeerAdvertisementInfo { + address: neighbor_router.sender, + first_seen: neighbor_router.first_seen, + when: neighbor_router.when, + router_lifetime: neighbor_router.adv.lifetime, + reachable_time: neighbor_router.adv.reachable_time, + retrans_timer: neighbor_router.adv.retrans_timer, + expired: neighbor_router.expired(), + }) + } + + /// List all interfaces managed by NDP with their detailed state. + /// + /// Returns information about each interface including local address, + /// advertised router lifetime, and discovered peer details (if any). + pub fn list_interfaces_detailed(&self) -> Vec { + let ifxs_guard = read_lock!(self.interfaces); + ifxs_guard + .iter() + .map(|iface_mgr| { + let nbr_guard = lock!(iface_mgr.inner.neighbor_router); + let discovered_peer = + nbr_guard.as_ref().map(|adv| PeerAdvertisementInfo { + address: adv.sender, + first_seen: adv.first_seen, + when: adv.when, + router_lifetime: adv.adv.lifetime, + reachable_time: adv.adv.reachable_time, + retrans_timer: adv.adv.retrans_timer, + expired: adv.expired(), + }); + + InterfaceAdvertisementInfo { + interface: iface_mgr.inner.ifx.clone(), + router_lifetime: iface_mgr.inner.router_lifetime, + discovered_peer, + } + }) + .collect() + } +} + +/// The `InterfaceNdpManager` runs router discovery for an individual interface. +/// +/// Discovery is started on construction and stopped automatically when the +/// interface manager is dropped via the `ManagedThread` Drop implementation. +#[derive(Debug)] +pub struct InterfaceNdpManager { + /// Handle to the transmit loop thread + _tx_thread: Arc, + /// Handle to the receive loop thread + _rx_thread: Arc, + inner: InterfaceNdpManagerInner, +} + +#[derive(Debug, Clone)] +struct InterfaceNdpManagerInner { + ifx: Ipv6NetworkInterface, + neighbor_router: Arc>>, + router_lifetime: u16, + log: Logger, +} + +#[derive(Debug, thiserror::Error)] +pub enum NewInterfaceNdpManagerError { + #[error("socket create: {0}")] + SocketCreate(ListeningSocketError), + + #[error("socket clone: {0}")] + SocketClone(std::io::Error), + + #[error("thread spawn error: {0}")] + ThreadSpawn(std::io::Error), +} + +impl InterfaceNdpManager { + /// Create a new interface manager for a given interface. + pub fn new( + ifx: Ipv6NetworkInterface, + router_lifetime: u16, + log: Logger, + ) -> Result, NewInterfaceNdpManagerError> { + let sk = create_socket(ifx.index) + .map_err(NewInterfaceNdpManagerError::SocketCreate)?; + + let ifname = ifx.name.clone(); + + let inner = InterfaceNdpManagerInner { + ifx, + neighbor_router: Arc::new(Mutex::new(None)), + router_lifetime, + log, + }; + + let tx_thread = Arc::new(ManagedThread::new()); + let tx_dropped = tx_thread.dropped_flag(); + tx_thread.start({ + let sk = sk + .try_clone() + .map_err(NewInterfaceNdpManagerError::SocketClone)?; + let s = inner.clone(); + Builder::new() + .name(format!("ndp_tx_{ifname}")) + .spawn(move || s.tx_loop(sk, tx_dropped)) + .map_err(NewInterfaceNdpManagerError::ThreadSpawn)? + }); + + let rx_thread = Arc::new(ManagedThread::new()); + let rx_dropped = rx_thread.dropped_flag(); + rx_thread.start({ + let sk = sk + .try_clone() + .map_err(NewInterfaceNdpManagerError::SocketClone)?; + let s = inner.clone(); + Builder::new() + .name(format!("ndp_rx_{ifname}")) + .spawn(move || s.rx_loop(sk, rx_dropped)) + .map_err(NewInterfaceNdpManagerError::ThreadSpawn)? + }); + + Ok(Arc::new(Self { + _tx_thread: tx_thread, + _rx_thread: rx_thread, + inner, + })) + } +} + +impl InterfaceNdpManagerInner { + /// Run the interface NDP manager receive loop. Advertisements are used to + /// set the current peer address. Advertisements are sent in response to + /// solicitations. + /// + /// A read timeout of 1 second is used. When the time out hits instead of + /// receiving a advertisement or solicitation packet, the current neighbor + /// (if any) is checked for expiration. + pub fn rx_loop(&self, s: Socket, dropped: Arc) { + const INTERVAL: Duration = Duration::from_secs(1); + loop { + if dropped.load(Ordering::SeqCst) { + break; + } + let _ds = DropSleep(INTERVAL); + + let mut buf: [MaybeUninit; 1024] = + [const { MaybeUninit::uninit() }; 1024]; + + match s.recv_from(&mut buf) { + Ok((len, src)) => { + let buf: &[u8] = unsafe { + std::slice::from_raw_parts(buf.as_ptr().cast(), len) + }; + let Some(src) = src.as_socket_ipv6().map(|x| *x.ip()) + else { + continue; + }; + if let Ok(ra) = Icmp6RouterAdvertisement::from_wire(buf) { + self.handle_ra(ra, src); + } + if let Ok(rs) = Icmp6RouterSolicitation::from_wire(buf) { + self.handle_rs(&s, rs, src); + } + } + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock { + self.check_expired(); + continue; + } + error!(self.log, "rx: {e}"); + } + } + } + } + + /// Start the transmit loop, periodically sending out announcements. + pub fn tx_loop(&self, sk: Socket, dropped: Arc) { + const INTERVAL: Duration = Duration::from_secs(5); + loop { + if dropped.load(Ordering::SeqCst) { + break; + } + send_ra( + &sk, + self.ifx.ip, + None, + self.ifx.index, + self.router_lifetime, + &self.log, + ); + send_rs(&sk, self.ifx.ip, None, self.ifx.index, &self.log); + sleep(INTERVAL); + } + } + + /// Handle a router advertisement. On reception the neighbor source address + /// is updated as well as the time of reception and the stored advertisement + /// containing the reachable time. + fn handle_ra(&self, ra: Icmp6RouterAdvertisement, src: Ipv6Addr) { + let mut guard = lock!(self.neighbor_router); + let now = Instant::now(); + + // Preserve first_seen from previous advertisement, or use now if this is the first + let first_seen = + guard.as_ref().map(|prev| prev.first_seen).unwrap_or(now); + + *guard = Some(ReceivedAdvertisement { + first_seen, + when: now, + adv: ra, + sender: src, + }); + } + + /// Handle a router solicitation by sending an announcement to the + /// sender. + fn handle_rs( + &self, + sk: &Socket, + // Don't really care what's in the solicitation for now, just + // care that it parses as a valid RS. + _rs: Icmp6RouterSolicitation, + src: Ipv6Addr, + ) { + send_ra( + sk, + self.ifx.ip, + Some(src), + self.ifx.index, + self.router_lifetime, + &self.log, + ); + } + + /// Check to see if the reachable time for our current peer (if any) + /// is expired. If so, remove the peer. + fn check_expired(&self) { + let mut guard = lock!(self.neighbor_router); + let Some(expired) = guard.as_ref().map(|nbr| nbr.expired()) else { + return; + }; + if expired { + *guard = None; + } + } +} + +/// Information about a network interface managed by the NDP manager. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Ipv6NetworkInterface { + /// Interface's name + pub name: String, + /// Interface's address + pub ip: Ipv6Addr, + /// Interface's index + pub index: u32, +} + +/// Detailed information about a discovered peer on an interface. +#[derive(Debug, Clone)] +pub struct PeerAdvertisementInfo { + /// The peer's IPv6 address + pub address: Ipv6Addr, + /// When the peer was first discovered + pub first_seen: Instant, + /// When the most recent Router Advertisement was received + pub when: Instant, + /// Router lifetime from the RA + pub router_lifetime: u16, + /// Reachable time from the RA + pub reachable_time: u32, + /// Retransmit timer from the RA + pub retrans_timer: u32, + /// Whether the peer has expired + pub expired: bool, +} + +/// Detailed information about an interface managed by NDP. +#[derive(Debug, Clone)] +pub struct InterfaceAdvertisementInfo { + /// The interface details + pub interface: Ipv6NetworkInterface, + /// The router lifetime we advertise + pub router_lifetime: u16, + /// Discovered peer information (if any) + pub discovered_peer: Option, +} diff --git a/ndp/src/packet.rs b/ndp/src/packet.rs new file mode 100644 index 00000000..84aa0964 --- /dev/null +++ b/ndp/src/packet.rs @@ -0,0 +1,151 @@ +//! Neighbor discovery protocol support crate + +// 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/ +// +// Copyright 2026 Oxide Computer Company + +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// ICMP6 router advertisement +/// +/// 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 +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Type | Code | Checksum | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Cur Hop Limit |M|O| Reserved | Router Lifetime | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Reachable Time | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Retrans Timer | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub struct Icmp6RouterAdvertisement { + pub typ: u8, + pub code: u8, + pub checksum: u16, + pub hop_limit: u8, + pub flags: u8, + pub lifetime: u16, + pub reachable_time: u32, + pub retrans_timer: u32, +} + +impl Icmp6RouterAdvertisement { + const TYPE: u8 = 134; + const CODE: u8 = 0; + const DEFAULT_HOPLIMIT: u8 = 255; + + pub fn from_wire(buf: &[u8]) -> Result { + let s: Self = ispf::from_bytes_be(buf)?; + if s.typ != Self::TYPE { + return Err(Icmp6RaFromWireError::WrongType(s.typ)); + } + if s.code != Self::CODE { + return Err(Icmp6RaFromWireError::WrongCode(s.code)); + } + Ok(s) + } + + // While RFC 4861 says 0 means unspecified, it's not clear how to interpret + // that from the perspective of a discovery engine. One interpretation may + // be that the reachable time is forever, another may be that reachable time + // is zero. Ten seconds is double our solicit interval, so if we get to a + // place where we don't have RAs inside 10 seconds, something has gone + // sideways. + pub fn effective_reachable_time(&self) -> Duration { + if self.reachable_time == 0 { + Duration::from_secs(10) + } else { + Duration::from_millis(self.reachable_time.into()) + } + } +} + +impl Default for Icmp6RouterAdvertisement { + fn default() -> Self { + Self { + typ: Self::TYPE, + code: Self::CODE, + checksum: 0, + hop_limit: Self::DEFAULT_HOPLIMIT, + flags: 0, + lifetime: 0, //indicates this is not a default router + reachable_time: 0, + retrans_timer: 0, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Icmp6RaFromWireError { + #[error("deserialization error: {0}")] + Ispf(#[from] ispf::Error), + + #[error("wrong type: expected {}, got {0}", Icmp6RouterAdvertisement::TYPE)] + WrongType(u8), + + #[error("wrong code: expected {}, got {0}", Icmp6RouterAdvertisement::CODE)] + WrongCode(u8), +} + +/// ICMP6 router solicitation +/// +/// 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 +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Type | Code | Checksum | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// | Reserved | +/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +/// +#[derive(Debug, Serialize, Deserialize, Clone, Copy)] +pub struct Icmp6RouterSolicitation { + pub typ: u8, + pub code: u8, + pub checksum: u16, + pub reserved: u32, +} + +impl Icmp6RouterSolicitation { + const TYPE: u8 = 133; + const CODE: u8 = 0; + + pub fn from_wire(buf: &[u8]) -> Result { + let s: Self = ispf::from_bytes_be(buf)?; + if s.typ != Self::TYPE { + return Err(Icmp6RsFromWireError::WrongType(s.typ)); + } + if s.code != Self::CODE { + return Err(Icmp6RsFromWireError::WrongCode(s.code)); + } + Ok(s) + } +} + +impl Default for Icmp6RouterSolicitation { + fn default() -> Self { + Self { + typ: Self::TYPE, + code: Self::CODE, + checksum: 0, + reserved: 0, + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Icmp6RsFromWireError { + #[error("deserialization error: {0}")] + Ispf(#[from] ispf::Error), + + #[error("wrong type: expected {}, got {0}", Icmp6RouterSolicitation::TYPE)] + WrongType(u8), + + #[error("wrong code: expected {}, got {0}", Icmp6RouterSolicitation::CODE)] + WrongCode(u8), +} diff --git a/ndp/src/util.rs b/ndp/src/util.rs new file mode 100644 index 00000000..d4d52ef0 --- /dev/null +++ b/ndp/src/util.rs @@ -0,0 +1,232 @@ +// 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/ +// +// Copyright 2026 Oxide Computer Company + +use crate::packet::{Icmp6RouterAdvertisement, Icmp6RouterSolicitation}; +use libc::{c_int, socklen_t}; +use slog::{Logger, error}; +use socket2::{Domain, Protocol, Socket, Type}; +use std::{ + ffi::c_void, + net::{Ipv6Addr, SocketAddrV6}, + os::fd::AsRawFd, + thread::sleep, + time::{Duration, Instant}, +}; + +pub const ALL_NODES_MCAST: Ipv6Addr = + Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 1); + +pub const ALL_ROUTERS_MCAST: Ipv6Addr = + Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 2); + +const ICMP6_RA_ULP_LEN: u32 = 16; +const ICMP6_RS_ULP_LEN: u32 = 8; + +#[derive(Debug, Clone)] +pub struct ReceivedAdvertisement { + /// When the peer was first discovered + pub first_seen: Instant, + /// When the most recent Router Advertisement was received + pub when: Instant, + pub adv: Icmp6RouterAdvertisement, + pub sender: Ipv6Addr, +} + +impl ReceivedAdvertisement { + pub fn expired(&self) -> bool { + self.when.elapsed() > self.adv.effective_reachable_time() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ListeningSocketError { + #[error("new socket error: {0}")] + NewSocketError(std::io::Error), + + #[error("reuse address error: {0}")] + ReuseAddress(std::io::Error), + + #[error("set multicast interface error: {0}")] + SetMulticastIf(std::io::Error), + + #[error("set multicast hops v6: {0}")] + SetMulticastHopsV6(std::io::Error), + + #[error("bind error: {0}")] + Bind(std::io::Error), + + #[error("set multicast loop error: {0}")] + SetMulticastLoop(std::io::Error), + + #[error("join all-nodes multicast group error: {0}")] + JoinAllNodesMulticast(std::io::Error), + + #[error("join all-routers multicast group error: {0}")] + JoinAllRoutersMulticast(std::io::Error), + + #[error("set read timeout error: {0}")] + SetReadTimeoutError(std::io::Error), + + #[error("failed to set ipv6 min hop count: {0}")] + SetIpv6MinHopCount(std::io::Error), +} + +pub fn send_ra( + s: &Socket, + src: Ipv6Addr, + dst: Option, + ifindex: u32, + router_lifetime: u16, + log: &Logger, +) { + let pkt = Icmp6RouterAdvertisement { + lifetime: router_lifetime, + ..Default::default() + }; + + let mut out = match ispf::to_bytes_be(&pkt) { + Ok(data) => data, + Err(e) => { + error!(log, "send_ra: serialize packet: {e}"); + return; + } + }; + cksum(src, dst, ICMP6_RA_ULP_LEN, &mut out); + + let dst = SocketAddrV6::new( + match dst { + Some(d) => d, + None => ALL_NODES_MCAST, + }, + 0, + 0, + ifindex, + ); + if let Err(e) = s.send_to(&out, &dst.into()) { + error!(log, "send_ra: send: {e}"); + } +} + +pub fn send_rs( + s: &Socket, + src: Ipv6Addr, + dst: Option, + ifindex: u32, + log: &Logger, +) { + let pkt = Icmp6RouterSolicitation::default(); + let mut out = match ispf::to_bytes_be(&pkt) { + Ok(data) => data, + Err(e) => { + error!(log, "send_rs: serialize packet: {e}"); + return; + } + }; + cksum(src, dst, ICMP6_RS_ULP_LEN, &mut out); + + let dst = SocketAddrV6::new( + match dst { + Some(d) => d, + None => ALL_ROUTERS_MCAST, + }, + 0, + 0, + ifindex, + ); + if let Err(e) = s.send_to(&out, &dst.into()) { + error!(log, "send_rs: send: {e}"); + } +} + +pub fn cksum( + src: Ipv6Addr, + dst: Option, + ulp_len: u32, + data: &mut [u8], +) { + // IP Protocol number for ICMP6 + const ICMP6_NEXT_HDR: u8 = 58; + + let mut ck = internet_checksum::Checksum::new(); + ck.add_bytes(&src.octets()); + ck.add_bytes( + &match dst { + Some(d) => d, + None => ALL_NODES_MCAST, + } + .octets(), + ); + ck.add_bytes(&ulp_len.to_be_bytes()); + ck.add_bytes(&[0, 0, 0, ICMP6_NEXT_HDR]); + ck.add_bytes(data); + let sum = ck.checksum(); + + // Checksum is the third octet of the ICMP packet. + data[2] = sum[0]; + data[3] = sum[1]; +} + +pub struct DropSleep(pub Duration); + +impl Drop for DropSleep { + fn drop(&mut self) { + sleep(self.0); + } +} + +/// Create a listening socket for solicitations and advertisements. This +/// socket listens on the unspecified address to pick up both unicast +/// and multicast solicitations and advertisements. +pub fn create_socket(index: u32) -> Result { + use ListeningSocketError as E; + const READ_TIMEOUT: Duration = Duration::from_secs(1); + + let s = Socket::new(Domain::IPV6, Type::RAW, Some(Protocol::ICMPV6)) + .map_err(E::NewSocketError)?; + + let sa = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, index).into(); + + s.set_reuse_address(true).map_err(E::ReuseAddress)?; + + s.set_multicast_if_v6(index).map_err(E::SetMulticastIf)?; + + s.set_multicast_hops_v6(255) + .map_err(E::SetMulticastHopsV6)?; + + s.set_multicast_loop_v6(false) + .map_err(E::SetMulticastLoop)?; + + s.join_multicast_v6(&ALL_NODES_MCAST, index) + .map_err(E::JoinAllNodesMulticast)?; + + s.join_multicast_v6(&ALL_ROUTERS_MCAST, index) + .map_err(E::JoinAllRoutersMulticast)?; + + s.bind(&sa).map_err(ListeningSocketError::Bind)?; + + s.set_read_timeout(Some(READ_TIMEOUT)) + .map_err(E::SetReadTimeoutError)?; + + unsafe { + // from + const IPV6_MINHOPCOUNT: c_int = 0x2f; + let min_hops: c_int = 255; + let rc = libc::setsockopt( + s.as_raw_fd(), + libc::IPPROTO_IPV6, + IPV6_MINHOPCOUNT, + &min_hops as *const _ as *const c_void, + std::mem::size_of::() as socklen_t, + ); + if rc < 0 { + return Err(ListeningSocketError::SetIpv6MinHopCount( + std::io::Error::last_os_error(), + )); + } + } + + Ok(s) +} diff --git a/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json b/openapi/mg-admin/mg-admin-4.0.0-15da6a.json similarity index 95% rename from openapi/mg-admin/mg-admin-4.0.0-9d15bb.json rename to openapi/mg-admin/mg-admin-4.0.0-15da6a.json index 3332678a..3e0a063e 100644 --- a/openapi/mg-admin/mg-admin-4.0.0-9d15bb.json +++ b/openapi/mg-admin/mg-admin-4.0.0-15da6a.json @@ -1508,6 +1508,17 @@ "$ref": "#/components/schemas/ShaperSource" } ] + }, + "unnumbered_peers": { + "description": "Lists of unnumbered peers indexed by peer group.", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnnumberedBgpPeerConfig" + } + } } }, "required": [ @@ -1825,6 +1836,7 @@ ] }, "BgpPathProperties": { + "description": "Pre-UNNUMBERED version of BgpPathProperties (peer is IpAddr). Used for API versions before VERSION_UNNUMBERED (5.0.0).", "type": "object", "properties": { "as_path": { @@ -1875,7 +1887,6 @@ ] }, "BgpPeerConfig": { - "description": "BGP peer configuration (current version with per-address-family policies).", "type": "object", "properties": { "communities": { @@ -2090,7 +2101,7 @@ "additionalProperties": false }, { - "description": "Multiple nexthop encoding capability as defined in RFC 8950. Note this capability is not yet implemented.", + "description": "Multiple nexthop encoding capability as defined in RFC 8950.", "type": "object", "properties": { "extended_next_hop_encoding": { @@ -3398,6 +3409,7 @@ }, "connect_retry_jitter": { "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", "allOf": [ { "$ref": "#/components/schemas/JitterRange" @@ -3410,6 +3422,7 @@ "minimum": 0 }, "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", "type": "boolean" }, "enforce_first_as": { @@ -3428,6 +3441,7 @@ }, "idle_hold_jitter": { "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", "allOf": [ { "$ref": "#/components/schemas/JitterRange" @@ -3821,6 +3835,7 @@ ] }, "Path": { + "description": "Pre-UNNUMBERED version of Path (uses BgpPathPropertiesV1). Used for API versions before VERSION_UNNUMBERED (5.0.0).", "type": "object", "properties": { "bgp": { @@ -4595,6 +4610,7 @@ ] }, "Rib": { + "description": "V1 Rib with PathV1", "type": "object", "additionalProperties": { "type": "array", @@ -4793,6 +4809,155 @@ } } }, + "UnnumberedBgpPeerConfig": { + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "interface": { + "type": "string" + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "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 + }, + "router_lifetime": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "hold_time", + "idle_hold_time", + "interface", + "keepalive", + "name", + "passive", + "resolution", + "router_lifetime" + ] + }, "UpdateErrorSubcode": { "description": "Update message error subcode types", "type": "string", diff --git a/openapi/mg-admin/mg-admin-5.0.0-5b8674.json b/openapi/mg-admin/mg-admin-5.0.0-5b8674.json new file mode 100644 index 00000000..6dbe65ff --- /dev/null +++ b/openapi/mg-admin/mg-admin-5.0.0-5b8674.json @@ -0,0 +1,5675 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Maghemite Admin", + "contact": { + "url": "https://oxide.computer", + "email": "api@oxide.computer" + }, + "version": "5.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_v2", + "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/clear/unnumbered-neighbor": { + "post": { + "operationId": "clear_unnumbered_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedNeighborResetRequest" + } + } + }, + "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": { + "put": { + "operationId": "create_neighbor_v3", + "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_v3", + "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" + } + } + } + }, + "/bgp/config/neighbor/{asn}/{peer}": { + "get": { + "operationId": "read_neighbor_v3", + "parameters": [ + { + "in": "path", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "path", + "name": "peer", + "description": "Peer identifier as a string.\n\n- For numbered peers: IP address (e.g., \"192.0.2.1\" or \"2001:db8::1\") - For unnumbered peers: Interface name (e.g., \"eth0\" or \"cxgbe0\")\n\nServer parses as IP address first; if parsing fails, treats as interface name. Uses PeerId::from_str() for type-safe conversion.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Neighbor" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_neighbor_v3", + "parameters": [ + { + "in": "path", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "path", + "name": "peer", + "description": "Peer identifier as a string.\n\n- For numbered peers: IP address (e.g., \"192.0.2.1\" or \"2001:db8::1\") - For unnumbered peers: Interface name (e.g., \"eth0\" or \"cxgbe0\")\n\nServer parses as IP address first; if parsing fails, treats as interface name. Uses PeerId::from_str() for type-safe conversion.", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/neighbors/{asn}": { + "get": { + "operationId": "read_neighbors_v3", + "parameters": [ + { + "in": "path", + "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/config/unnumbered-neighbor": { + "get": { + "operationId": "read_unnumbered_neighbor", + "parameters": [ + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "query", + "name": "interface", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedNeighbor" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "operationId": "create_unnumbered_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedNeighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "post": { + "operationId": "update_unnumbered_neighbor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnnumberedNeighbor" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "delete": { + "operationId": "delete_unnumbered_neighbor", + "parameters": [ + { + "in": "query", + "name": "asn", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "query", + "name": "interface", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "successful deletion" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/config/unnumbered-neighbors": { + "get": { + "operationId": "read_unnumbered_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_UnnumberedNeighbor", + "type": "array", + "items": { + "$ref": "#/components/schemas/UnnumberedNeighbor" + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/bgp/history/fsm": { + "get": { + "operationId": "fsm_history_v2", + "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_v3", + "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_v2", + "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_v2", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportedSelector" + } + } + }, + "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_v4", + "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" + } + } + } + }, + "/ndp/interface": { + "get": { + "operationId": "get_ndp_interface_detail", + "parameters": [ + { + "in": "query", + "name": "asn", + "description": "ASN of the router", + "required": true, + "schema": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + { + "in": "query", + "name": "interface", + "description": "Interface name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NdpInterface" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/ndp/interfaces": { + "get": { + "operationId": "get_ndp_interfaces", + "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_NdpInterface", + "type": "array", + "items": { + "$ref": "#/components/schemas/NdpInterface" + } + } + } + } + }, + "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_v2", + "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_v2", + "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" + } + } + } + }, + "/switch/identifiers": { + "get": { + "operationId": "switch_identifiers", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SwitchIdentifiers" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + } + }, + "components": { + "schemas": { + "AddPathElement": { + "description": "The add path element comes as a BGP capability extension as described in RFC 7911.", + "type": "object", + "properties": { + "afi": { + "description": "Address family identifier. ", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "description": "Subsequent address family identifier. There are a large pile of these ", + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "send_receive": { + "description": "This field indicates whether the sender is (a) able to receive multiple paths from its peer (value 1), (b) able to send multiple paths to its peer (value 2), or (c) both (value 3) for the .", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi", + "send_receive" + ] + }, + "AddStaticRoute4Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute4List" + } + }, + "required": [ + "routes" + ] + }, + "AddStaticRoute6Request": { + "type": "object", + "properties": { + "routes": { + "$ref": "#/components/schemas/StaticRoute6List" + } + }, + "required": [ + "routes" + ] + }, + "Afi": { + "description": "Address families supported by Maghemite BGP.", + "oneOf": [ + { + "description": "Internet protocol version 4", + "type": "string", + "enum": [ + "Ipv4" + ] + }, + { + "description": "Internet protocol version 6", + "type": "string", + "enum": [ + "Ipv6" + ] + } + ] + }, + "AfiSafi": { + "type": "object", + "properties": { + "afi": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "safi": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi", + "safi" + ] + }, + "Aggregator": { + "description": "AGGREGATOR path attribute (RFC 4271 §5.1.8)\n\nThe AGGREGATOR attribute is an optional transitive attribute that contains the AS number and IP address of the last BGP speaker that formed the aggregate route.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (2-octet)", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "ApplyRequest": { + "description": "Apply changes to an ASN (current version with per-AF policies).", + "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.", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "peers": { + "description": "Lists of peers indexed by peer group.", + "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" + } + ] + }, + "unnumbered_peers": { + "description": "Lists of unnumbered peers indexed by peer group.", + "default": {}, + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UnnumberedBgpPeerConfig" + } + } + } + }, + "required": [ + "asn", + "originate", + "peers" + ] + }, + "As4Aggregator": { + "description": "AS4_AGGREGATOR path attribute (RFC 6793)\n\nThe AS4_AGGREGATOR attribute is an optional transitive attribute with the same semantics as AGGREGATOR, but carries a 4-octet AS number instead of 2-octet.", + "type": "object", + "properties": { + "address": { + "description": "IP address of the BGP speaker that formed the aggregate", + "type": "string", + "format": "ipv4" + }, + "asn": { + "description": "Autonomous System Number that formed the aggregate (4-octet)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "address", + "asn" + ] + }, + "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" + ] + } + ] + }, + "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" + ] + } + ] + }, + "BgpCapability": { + "oneOf": [ + { + "type": "string", + "enum": [ + "RouteRefresh" + ] + }, + { + "type": "object", + "properties": { + "MultiprotocolExtensions": { + "$ref": "#/components/schemas/AfiSafi" + } + }, + "required": [ + "MultiprotocolExtensions" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "FourOctetAsn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "FourOctetAsn" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "AddPath": { + "type": "object", + "properties": { + "elements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AfiSafi" + } + } + }, + "required": [ + "elements" + ] + } + }, + "required": [ + "AddPath" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Unknown": { + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "Unknown" + ], + "additionalProperties": false + } + ] + }, + "BgpNexthop": { + "description": "A BGP next-hop address in one of three formats: IPv4, IPv6 single, or IPv6 double.", + "oneOf": [ + { + "type": "object", + "properties": { + "Ipv4": { + "type": "string", + "format": "ipv4" + } + }, + "required": [ + "Ipv4" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Single": { + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "Ipv6Single" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Ipv6Double": { + "$ref": "#/components/schemas/Ipv6DoubleNexthop" + } + }, + "required": [ + "Ipv6Double" + ], + "additionalProperties": false + } + ] + }, + "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": { + "$ref": "#/components/schemas/PeerId" + }, + "stale": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + "required": [ + "as_path", + "id", + "origin_as", + "peer" + ] + }, + "BgpPeerConfig": { + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "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": [ + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "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.", + "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" + }, + "remaining": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "negotiated", + "remaining" + ] + }, + "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 + } + ] + }, + "ExportedSelector": { + "type": "object", + "properties": { + "afi": { + "nullable": true, + "description": "Optional address family filter (None = all negotiated families)", + "allOf": [ + { + "$ref": "#/components/schemas/Afi" + } + ] + }, + "asn": { + "description": "ASN of the router to get exported prefixes from.", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "peer": { + "nullable": true, + "description": "Optional peer filter using PeerId enum", + "allOf": [ + { + "$ref": "#/components/schemas/PeerId" + } + ] + } + }, + "required": [ + "asn" + ] + }, + "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": { + "description": "Unified FSM history request supporting both numbered and unnumbered peers", + "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 using PeerId enum JSON format: {\"ip\": \"192.0.2.1\"} or {\"interface\": \"eth0\"}", + "allOf": [ + { + "$ref": "#/components/schemas/PeerId" + } + ] + } + }, + "required": [ + "asn" + ] + }, + "FsmHistoryResponse": { + "description": "Unified FSM history response with string keys from PeerId Display Keys will be \"192.0.2.1\" or \"eth0\" format", + "type": "object", + "properties": { + "by_peer": { + "description": "Events organized by peer identifier 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 keepalive 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" + ] + }, + "ImportExportPolicy4": { + "description": "Import/Export policy for IPv4 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "ImportExportPolicy6": { + "description": "Import/Export policy for IPv6 prefixes only.", + "oneOf": [ + { + "type": "string", + "enum": [ + "NoFiltering" + ] + }, + { + "type": "object", + "properties": { + "Allow": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + }, + "uniqueItems": true + } + }, + "required": [ + "Allow" + ], + "additionalProperties": false + } + ] + }, + "Ipv4UnicastConfig": { + "description": "Per-address-family configuration for IPv4 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy4" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "Ipv6DoubleNexthop": { + "description": "IPv6 double nexthop: global unicast address + link-local address. Per RFC 2545, when advertising IPv6 routes, both addresses may be present.", + "type": "object", + "properties": { + "global": { + "description": "Global unicast address", + "type": "string", + "format": "ipv6" + }, + "link_local": { + "description": "Link-local address", + "type": "string", + "format": "ipv6" + } + }, + "required": [ + "global", + "link_local" + ] + }, + "Ipv6UnicastConfig": { + "description": "Per-address-family configuration for IPv6 Unicast", + "type": "object", + "properties": { + "export_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "import_policy": { + "$ref": "#/components/schemas/ImportExportPolicy6" + }, + "nexthop": { + "nullable": true, + "type": "string", + "format": "ip" + } + }, + "required": [ + "export_policy", + "import_policy" + ] + }, + "JitterRange": { + "description": "Jitter range with minimum and maximum multiplier values. When applied to a timer, the timer duration is multiplied by a random value within [min, max] to help break synchronization patterns.", + "type": "object", + "properties": { + "max": { + "description": "Maximum jitter multiplier (typically 1.0 or similar)", + "type": "number", + "format": "double" + }, + "min": { + "description": "Minimum jitter multiplier (typically 0.75 or similar)", + "type": "number", + "format": "double" + } + }, + "required": [ + "max", + "min" + ] + }, + "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": { + "description": "Unified message history request supporting both numbered and unnumbered peers", + "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 using PeerId enum JSON format: {\"ip\": \"192.0.2.1\"} or {\"interface\": \"eth0\"}", + "allOf": [ + { + "$ref": "#/components/schemas/PeerId" + } + ] + } + }, + "required": [ + "asn" + ] + }, + "MessageHistoryResponse": { + "description": "Unified message history response with string keys from PeerId Display Keys will be \"192.0.2.1\" or \"eth0\" format", + "type": "object", + "properties": { + "by_peer": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MessageHistory" + } + } + }, + "required": [ + "by_peer" + ] + }, + "MpReachNlri": { + "description": "MP_REACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being announced.\n\n```text 3. Multiprotocol Reachable NLRI - MP_REACH_NLRI (Type Code 14):\n\nThis is an optional non-transitive attribute that can be used for the following purposes:\n\n(a) to advertise a feasible route to a peer\n\n(b) to permit a router to advertise the Network Layer address of the router that should be used as the next hop to the destinations listed in the Network Layer Reachability Information field of the MP_NLRI attribute.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv4 routes.\n\nCurrently must be `BgpNexthop::Ipv4`, but will support IPv6 nexthops when extended next-hop capability (RFC 8950) is implemented.", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv4 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + }, + { + "description": "IPv6 Unicast routes (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "nexthop": { + "description": "Next-hop for IPv6 routes.\n\nCan be `BgpNexthop::Ipv6Single` (16 bytes) or `BgpNexthop::Ipv6Double` (32 bytes with link-local address).", + "allOf": [ + { + "$ref": "#/components/schemas/BgpNexthop" + } + ] + }, + "nlri": { + "description": "IPv6 prefixes being announced", + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + }, + "reserved": { + "description": "Reserved byte from RFC 4760 §3 (historically \"Number of SNPAs\" in RFC 2858). MUST be 0 per RFC 4760, but MUST be ignored by receiver. Stored for validation logging in session layer. This field is positioned before NLRI to match the wire format encoding.", + "type": "integer", + "format": "uint8", + "minimum": 0 + } + }, + "required": [ + "afi_safi", + "nexthop", + "nlri", + "reserved" + ] + } + ] + }, + "MpUnreachNlri": { + "description": "MP_UNREACH_NLRI path attribute\n\nEach variant represents a specific AFI+SAFI combination, providing compile-time guarantees about the address family of routes being withdrawn.\n\n```text 4. Multiprotocol Unreachable NLRI - MP_UNREACH_NLRI (Type Code 15):\n\nThis is an optional non-transitive attribute that can be used for the purpose of withdrawing multiple unfeasible routes from service.\n\nThe attribute is encoded as shown below:\n\n+---------------------------------------------------------+ | Address Family Identifier (2 octets) | +---------------------------------------------------------+ | Subsequent Address Family Identifier (1 octet) | +---------------------------------------------------------+ | Withdrawn Routes (variable) | +---------------------------------------------------------+ ```", + "oneOf": [ + { + "description": "IPv4 Unicast routes being withdrawn (AFI=1, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv4_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + }, + { + "description": "IPv6 Unicast routes being withdrawn (AFI=2, SAFI=1)", + "type": "object", + "properties": { + "afi_safi": { + "type": "string", + "enum": [ + "ipv6_unicast" + ] + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix6" + } + } + }, + "required": [ + "afi_safi", + "withdrawn" + ] + } + ] + }, + "NdpInterface": { + "description": "NDP state for an interface", + "type": "object", + "properties": { + "discovered_peer": { + "nullable": true, + "description": "Information about discovered peer (if any, including expired)", + "allOf": [ + { + "$ref": "#/components/schemas/NdpPeer" + } + ] + }, + "interface": { + "description": "Interface name (e.g., \"qsfp0\")", + "type": "string" + }, + "local_address": { + "description": "Local IPv6 link-local address", + "type": "string", + "format": "ipv6" + }, + "router_lifetime": { + "description": "Router lifetime advertised by this router (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "scope_id": { + "description": "IPv6 scope ID (interface index)", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "interface", + "local_address", + "router_lifetime", + "scope_id" + ] + }, + "NdpPeer": { + "description": "Information about a discovered NDP peer", + "type": "object", + "properties": { + "address": { + "description": "Peer IPv6 address", + "type": "string", + "format": "ipv6" + }, + "discovered_at": { + "description": "When the peer was first discovered (ISO 8601 timestamp)", + "type": "string" + }, + "expired": { + "description": "Whether the peer entry has expired", + "type": "boolean" + }, + "last_advertisement": { + "description": "When the most recent Router Advertisement was received (ISO 8601 timestamp)", + "type": "string" + }, + "reachable_time": { + "description": "Reachable time from RA (milliseconds)", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "retrans_timer": { + "description": "Retransmit timer from RA (milliseconds)", + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "router_lifetime": { + "description": "Router lifetime from RA (seconds)", + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "time_until_expiry": { + "nullable": true, + "description": "Time until expiry (human-readable), or None if already expired", + "type": "string" + } + }, + "required": [ + "address", + "discovered_at", + "expired", + "last_advertisement", + "reachable_time", + "retrans_timer", + "router_lifetime" + ] + }, + "Neighbor": { + "description": "Neighbor configuration with explicit per-address-family enablement (v3 API)", + "type": "object", + "properties": { + "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 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "host": { + "type": "string" + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "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": [ + "asn", + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "group", + "hold_time", + "host", + "idle_hold_time", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "NeighborResetOp": { + "description": "V2 API neighbor reset operations with per-AF support", + "oneOf": [ + { + "description": "Hard reset - closes TCP connection and restarts session", + "type": "string", + "enum": [ + "Hard" + ] + }, + { + "description": "Soft inbound reset - sends route refresh for specified AF(s) None means all negotiated AFs", + "type": "object", + "properties": { + "SoftInbound": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Afi" + } + ] + } + }, + "required": [ + "SoftInbound" + ], + "additionalProperties": false + }, + { + "description": "Soft outbound reset - re-advertises routes for specified AF(s) None means all negotiated AFs", + "type": "object", + "properties": { + "SoftOutbound": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/Afi" + } + ] + } + }, + "required": [ + "SoftOutbound" + ], + "additionalProperties": false + } + ] + }, + "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" + }, + "nexthop_interface": { + "nullable": true, + "description": "Interface binding for nexthop resolution.\n\nThis field is only populated for BGP unnumbered sessions where the nexthop is a link-local IPv6 address. For numbered peers, this is always None.\n\nAdded in API version 5.0.0 (UNNUMBERED).", + "type": "string" + }, + "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", + "mp_unreach_nlri", + "as4_aggregator" + ] + }, + { + "description": "RFC 4271", + "type": "string", + "enum": [ + "origin" + ] + }, + { + "description": "RFC 4760", + "type": "string", + "enum": [ + "mp_reach_nlri" + ] + }, + { + "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 (IPv4 only for traditional BGP)", + "type": "object", + "properties": { + "next_hop": { + "type": "string", + "format": "ipv4" + } + }, + "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": "AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (2-octet ASN)", + "type": "object", + "properties": { + "aggregator": { + "$ref": "#/components/schemas/Aggregator" + } + }, + "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": "Indicates this route was formed via aggregation (RFC 4271 §5.1.7)", + "type": "string", + "enum": [ + "atomic_aggregate" + ] + }, + { + "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": "AS4_AGGREGATOR: AS number and IP address of the last aggregating BGP speaker (4-octet ASN)", + "type": "object", + "properties": { + "as4_aggregator": { + "$ref": "#/components/schemas/As4Aggregator" + } + }, + "required": [ + "as4_aggregator" + ], + "additionalProperties": false + }, + { + "description": "Carries reachable MP-BGP NLRI and Next-hop (advertisement).", + "type": "object", + "properties": { + "mp_reach_nlri": { + "$ref": "#/components/schemas/MpReachNlri" + } + }, + "required": [ + "mp_reach_nlri" + ], + "additionalProperties": false + }, + { + "description": "Carries unreachable MP-BGP NLRI (withdrawal).", + "type": "object", + "properties": { + "mp_unreach_nlri": { + "$ref": "#/components/schemas/MpUnreachNlri" + } + }, + "required": [ + "mp_unreach_nlri" + ], + "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" + ] + } + ] + }, + "PeerCounters": { + "description": "Session-level counters that persist across connection changes These serve as aggregate counters across all connections for the session", + "type": "object", + "properties": { + "active_connections_accepted": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "active_connections_declined": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connection_retries": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connector_panics": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "hold_timer_expirations": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_timer_expirations": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalive_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalives_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "keepalives_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "md5_auth_failures": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notification_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notifications_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "notifications_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "open_handle_failures": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "open_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "opens_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "opens_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "passive_connections_accepted": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "passive_connections_declined": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "prefixes_advertised": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "prefixes_imported": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "route_refresh_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "tcp_connection_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_active": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_connect": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_connection_collision": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_established": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_idle": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_open_confirm": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_open_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "transitions_to_session_setup": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_keepalive_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_notification_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_open_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_route_refresh_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unexpected_update_message": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "unnegotiated_address_family": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "update_nexhop_missing": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "update_send_failure": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "updates_received": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "updates_sent": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "active_connections_accepted", + "active_connections_declined", + "connection_retries", + "connector_panics", + "hold_timer_expirations", + "idle_hold_timer_expirations", + "keepalive_send_failure", + "keepalives_received", + "keepalives_sent", + "md5_auth_failures", + "notification_send_failure", + "notifications_received", + "notifications_sent", + "open_handle_failures", + "open_send_failure", + "opens_received", + "opens_sent", + "passive_connections_accepted", + "passive_connections_declined", + "prefixes_advertised", + "prefixes_imported", + "route_refresh_received", + "route_refresh_send_failure", + "route_refresh_sent", + "tcp_connection_failure", + "transitions_to_active", + "transitions_to_connect", + "transitions_to_connection_collision", + "transitions_to_established", + "transitions_to_idle", + "transitions_to_open_confirm", + "transitions_to_open_sent", + "transitions_to_session_setup", + "unexpected_keepalive_message", + "unexpected_notification_message", + "unexpected_open_message", + "unexpected_route_refresh_message", + "unexpected_update_message", + "unnegotiated_address_family", + "update_nexhop_missing", + "update_send_failure", + "updates_received", + "updates_sent" + ] + }, + "PeerId": { + "description": "Identifies a BGP peer for session management and route tracking.\n\nBGP peers can be identified in two ways: - **Numbered**: Traditional BGP peering using explicit IP addresses - **Unnumbered**: Modern peering using interface names with link-local addresses\n\n# Unnumbered Peering\n\nUnnumbered BGP uses interface names as stable identifiers instead of IP addresses. This is important because: - Link-local IPv6 addresses are discovered dynamically via NDP - Multiple interfaces may have peers with the same link-local address (e.g., fe80::1 on eth0 and fe80::1 on eth1) - Scope ID (interface index) disambiguates link-local addresses, but is not stable across reboots - Interface names provide stable, unambiguous peer identification\n\n# Route Tracking\n\nThis type is used in [`BgpPathProperties`](crate::BgpPathProperties) to track which peer advertised a route. Using `PeerId` instead of `IpAddr` ensures: - Unnumbered peers are properly distinguished even if they share link-local IPs - Route cleanup correctly removes only the routes from the intended peer - No cross-contamination when multiple unnumbered sessions exist\n\n# Examples\n\n``` use rdb_types::PeerId; use std::net::IpAddr;\n\n// Numbered peer let numbered = PeerId::Ip(\"192.0.2.1\".parse::().unwrap());\n\n// Unnumbered peer let unnumbered = PeerId::Interface(\"eth0\".to_string()); ```", + "oneOf": [ + { + "description": "Numbered peer identified by IP address\n\nUsed for traditional BGP sessions where peers are configured with explicit IP addresses (either IPv4 or IPv6 global unicast).", + "type": "object", + "properties": { + "Ip": { + "type": "string", + "format": "ip" + } + }, + "required": [ + "Ip" + ], + "additionalProperties": false + }, + { + "description": "Unnumbered peer identified by interface name\n\nUsed for unnumbered BGP sessions where peers are discovered via NDP on a specific interface. The interface name (e.g., \"eth0\") provides stable identification even though the peer's link-local address may be dynamic or shared with other interfaces.", + "type": "object", + "properties": { + "Interface": { + "type": "string" + } + }, + "required": [ + "Interface" + ], + "additionalProperties": false + } + ] + }, + "PeerInfo": { + "type": "object", + "properties": { + "asn": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "counters": { + "$ref": "#/components/schemas/PeerCounters" + }, + "fsm_state": { + "$ref": "#/components/schemas/FsmStateKind" + }, + "fsm_state_duration": { + "$ref": "#/components/schemas/Duration" + }, + "id": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "ipv4_unicast": { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + }, + "ipv6_unicast": { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + }, + "local_ip": { + "type": "string", + "format": "ip" + }, + "local_tcp_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "name": { + "type": "string" + }, + "peer_group": { + "type": "string" + }, + "received_capabilities": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BgpCapability" + } + }, + "remote_ip": { + "type": "string", + "format": "ip" + }, + "remote_tcp_port": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "timers": { + "$ref": "#/components/schemas/PeerTimers" + } + }, + "required": [ + "counters", + "fsm_state", + "fsm_state_duration", + "ipv4_unicast", + "ipv6_unicast", + "local_ip", + "local_tcp_port", + "name", + "peer_group", + "received_capabilities", + "remote_ip", + "remote_tcp_port", + "timers" + ] + }, + "PeerTimers": { + "type": "object", + "properties": { + "connect_retry": { + "$ref": "#/components/schemas/StaticTimerInfo" + }, + "connect_retry_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "$ref": "#/components/schemas/StaticTimerInfo" + }, + "hold": { + "$ref": "#/components/schemas/DynamicTimerInfo" + }, + "idle_hold": { + "$ref": "#/components/schemas/StaticTimerInfo" + }, + "idle_hold_jitter": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "keepalive": { + "$ref": "#/components/schemas/DynamicTimerInfo" + } + }, + "required": [ + "connect_retry", + "delay_open", + "hold", + "idle_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" + ] + }, + "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" + ] + }, + "StaticTimerInfo": { + "description": "Timer information for static (non-negotiated) timers", + "type": "object", + "properties": { + "configured": { + "$ref": "#/components/schemas/Duration" + }, + "remaining": { + "$ref": "#/components/schemas/Duration" + } + }, + "required": [ + "configured", + "remaining" + ] + }, + "SwitchIdentifiers": { + "description": "Identifiers for a switch.", + "type": "object", + "properties": { + "slot": { + "nullable": true, + "description": "The slot number of the switch being managed.\n\nMGS uses u16 for this internally.", + "type": "integer", + "format": "uint16", + "minimum": 0 + } + } + }, + "UnnumberedBgpPeerConfig": { + "type": "object", + "properties": { + "communities": { + "type": "array", + "items": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "connect_retry": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "interface": { + "type": "string" + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "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 + }, + "router_lifetime": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "vlan_id": { + "nullable": true, + "type": "integer", + "format": "uint16", + "minimum": 0 + } + }, + "required": [ + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "hold_time", + "idle_hold_time", + "interface", + "keepalive", + "name", + "passive", + "resolution", + "router_lifetime" + ] + }, + "UnnumberedNeighbor": { + "type": "object", + "properties": { + "act_as_a_default_ipv6_router": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "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 + }, + "connect_retry_jitter": { + "nullable": true, + "description": "Jitter range for connect_retry timer. When used, the connect_retry timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "delay_open": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "deterministic_collision_resolution": { + "description": "Enable deterministic collision resolution in Established state. When true, uses BGP-ID comparison per RFC 4271 §6.8 for collision resolution even when one connection is already in Established state. When false, Established connection always wins (timing-based resolution).", + "type": "boolean" + }, + "enforce_first_as": { + "type": "boolean" + }, + "group": { + "type": "string" + }, + "hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "idle_hold_jitter": { + "nullable": true, + "description": "Jitter range for idle hold timer. When used, the idle hold timer is multiplied by a random value within the (min, max) range supplied. Useful to help break repeated synchronization of connection collisions.", + "allOf": [ + { + "$ref": "#/components/schemas/JitterRange" + } + ] + }, + "idle_hold_time": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "interface": { + "type": "string" + }, + "ipv4_unicast": { + "nullable": true, + "description": "IPv4 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4UnicastConfig" + } + ] + }, + "ipv6_unicast": { + "nullable": true, + "description": "IPv6 Unicast address family configuration (None = disabled)", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6UnicastConfig" + } + ] + }, + "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": [ + "act_as_a_default_ipv6_router", + "asn", + "communities", + "connect_retry", + "delay_open", + "deterministic_collision_resolution", + "enforce_first_as", + "group", + "hold_time", + "idle_hold_time", + "interface", + "keepalive", + "name", + "passive", + "resolution" + ] + }, + "UnnumberedNeighborResetRequest": { + "type": "object", + "properties": { + "asn": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "interface": { + "type": "string" + }, + "op": { + "$ref": "#/components/schemas/NeighborResetOp" + } + }, + "required": [ + "asn", + "interface", + "op" + ] + }, + "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/Prefix4" + } + }, + "path_attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PathAttribute" + } + }, + "withdrawn": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prefix4" + } + } + }, + "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" + ] + } + ] + }, + "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 bb139baa..b907439a 120000 --- a/openapi/mg-admin/mg-admin-latest.json +++ b/openapi/mg-admin/mg-admin-latest.json @@ -1 +1 @@ -mg-admin-4.0.0-9d15bb.json \ No newline at end of file +mg-admin-5.0.0-5b8674.json \ No newline at end of file diff --git a/rdb-types/src/lib.rs b/rdb-types/src/lib.rs index 99fdf415..4705985c 100644 --- a/rdb-types/src/lib.rs +++ b/rdb-types/src/lib.rs @@ -441,3 +441,143 @@ pub enum ProtocolFilter { /// Static routes only Static, } + +/// Identifies a BGP peer for session management and route tracking. +/// +/// BGP peers can be identified in two ways: +/// - **Numbered**: Traditional BGP peering using explicit IP addresses +/// - **Unnumbered**: Modern peering using interface names with link-local addresses +/// +/// # Unnumbered Peering +/// +/// Unnumbered BGP uses interface names as stable identifiers instead of IP addresses. +/// This is important because: +/// - Link-local IPv6 addresses are discovered dynamically via NDP +/// - Multiple interfaces may have peers with the same link-local address +/// (e.g., fe80::1 on eth0 and fe80::1 on eth1) +/// - Scope ID (interface index) disambiguates link-local addresses, but is not +/// stable across reboots +/// - Interface names provide stable, unambiguous peer identification +/// +/// # Route Tracking +/// +/// This type is used in [`BgpPathProperties`](crate::BgpPathProperties) to track +/// which peer advertised a route. Using `PeerId` instead of `IpAddr` ensures: +/// - Unnumbered peers are properly distinguished even if they share link-local IPs +/// - Route cleanup correctly removes only the routes from the intended peer +/// - No cross-contamination when multiple unnumbered sessions exist +/// +/// # Examples +/// +/// ``` +/// use rdb_types::PeerId; +/// use std::net::IpAddr; +/// +/// // Numbered peer +/// let numbered = PeerId::Ip("192.0.2.1".parse::().unwrap()); +/// +/// // Unnumbered peer +/// let unnumbered = PeerId::Interface("eth0".to_string()); +/// ``` +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + PartialOrd, + Ord, + Serialize, + Deserialize, + JsonSchema, +)] +pub enum PeerId { + /// Numbered peer identified by IP address + /// + /// Used for traditional BGP sessions where peers are configured with + /// explicit IP addresses (either IPv4 or IPv6 global unicast). + Ip(IpAddr), + + /// Unnumbered peer identified by interface name + /// + /// Used for unnumbered BGP sessions where peers are discovered via NDP + /// on a specific interface. The interface name (e.g., "eth0") provides + /// stable identification even though the peer's link-local address may + /// be dynamic or shared with other interfaces. + Interface(String), +} + +impl fmt::Display for PeerId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Ip(ip) => write!(f, "{}", ip), + Self::Interface(name) => write!(f, "{}", name), + } + } +} + +impl From for PeerId { + fn from(ip: IpAddr) -> Self { + Self::Ip(ip) + } +} + +impl From<&str> for PeerId { + fn from(s: &str) -> Self { + // Try to parse as IP first, otherwise treat as interface name + if let Ok(ip) = s.parse::() { + Self::Ip(ip) + } else { + Self::Interface(s.to_string()) + } + } +} + +impl From for PeerId { + fn from(s: String) -> Self { + // Try to parse as IP first, otherwise treat as interface name + if let Ok(ip) = s.parse::() { + Self::Ip(ip) + } else { + Self::Interface(s) + } + } +} + +impl From for PeerId { + fn from(ip: Ipv4Addr) -> Self { + Self::Ip(IpAddr::V4(ip)) + } +} + +impl From for PeerId { + fn from(ip: Ipv6Addr) -> Self { + Self::Ip(IpAddr::V6(ip)) + } +} + +impl FromStr for PeerId { + type Err = std::convert::Infallible; + + /// Parse a PeerId from a string representation. + /// Attempts to parse as an IP address first; if that fails, treats it as an interface name. + fn from_str(s: &str) -> Result { + if let Ok(ip) = s.parse::() { + Ok(Self::Ip(ip)) + } else { + Ok(Self::Interface(s.to_string())) + } + } +} + +impl PeerId { + /// Check if this represents an unnumbered peer + pub fn is_unnumbered(&self) -> bool { + matches!(self, Self::Interface(_)) + } + + /// Check if this represents a numbered peer + pub fn is_numbered(&self) -> bool { + matches!(self, Self::Ip(_)) + } +} diff --git a/rdb/Cargo.toml b/rdb/Cargo.toml index b4c228e2..7cce9782 100644 --- a/rdb/Cargo.toml +++ b/rdb/Cargo.toml @@ -18,6 +18,7 @@ chrono.workspace = true clap = { workspace = true, optional = true } oxnet.workspace = true rdb-types = { workspace = true, features = ["clap"] } +ndp.workspace = true [dev-dependencies] proptest.workspace = true diff --git a/rdb/src/bestpath.rs b/rdb/src/bestpath.rs index 6e62cefa..58c8c546 100644 --- a/rdb/src/bestpath.rs +++ b/rdb/src/bestpath.rs @@ -125,6 +125,7 @@ pub fn bgp_bestpaths( #[cfg(test)] mod test { + use crate::PeerId; use std::collections::BTreeSet; use std::net::IpAddr; use std::str::FromStr; @@ -150,11 +151,12 @@ mod test { // Add one path and make sure we get it back let path1 = Path { nexthop: remote_ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 470, - peer: remote_ip1, + peer: PeerId::Ip(remote_ip1), id: 47, med: Some(75), local_pref: Some(100), @@ -174,11 +176,12 @@ mod test { // Add path2: let mut path2 = Path { nexthop: remote_ip2, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 480, - peer: remote_ip2, + peer: PeerId::Ip(remote_ip2), id: 48, med: Some(75), local_pref: Some(100), @@ -208,11 +211,12 @@ mod test { // filter. The max=2 limit determines which paths are returned. let path3 = Path { nexthop: remote_ip3, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 490, - peer: remote_ip3, + peer: PeerId::Ip(remote_ip3), id: 49, med: Some(100), local_pref: Some(100), @@ -261,6 +265,7 @@ mod test { // > static is preferred over bgp when RIB priority matches let mut path4 = Path { nexthop: remote_ip4, + nexthop_interface: None, rib_priority: u8::MAX, shutdown: false, bgp: None, @@ -307,11 +312,12 @@ mod test { // Create two equivalent BGP paths, but one is shutdown let active_path = Path { nexthop: remote_ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 470, - peer: remote_ip1, + peer: PeerId::Ip(remote_ip1), id: 47, med: Some(75), local_pref: Some(100), @@ -323,11 +329,12 @@ mod test { let shutdown_path = Path { nexthop: remote_ip2, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: true, // This path is shutdown bgp: Some(BgpPathProperties { origin_as: 480, - peer: remote_ip2, + peer: PeerId::Ip(remote_ip2), id: 48, med: Some(75), local_pref: Some(100), @@ -367,11 +374,12 @@ mod test { // Test with two shutdown paths - both should be returned when max=2 let shutdown_path2 = Path { nexthop: remote_ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: true, bgp: Some(BgpPathProperties { origin_as: 470, - peer: remote_ip1, + peer: PeerId::Ip(remote_ip1), id: 47, med: Some(75), local_pref: Some(100), @@ -413,11 +421,12 @@ mod test { // Path from ip3 has MED 100 (worse) let as100_path_good = Path { nexthop: ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 100, - peer: ip1, + peer: PeerId::Ip(ip1), id: 1, med: Some(50), local_pref: Some(100), @@ -429,11 +438,12 @@ mod test { let as100_path_bad = Path { nexthop: ip3, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 100, - peer: ip3, + peer: PeerId::Ip(ip3), id: 1, med: Some(100), // Higher MED = worse local_pref: Some(100), @@ -447,11 +457,12 @@ mod test { // This should NOT be excluded just because AS 100 has a lower MED let as200_path = Path { nexthop: ip2, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 200, - peer: ip2, + peer: PeerId::Ip(ip2), id: 2, med: Some(999), // Very high MED, but irrelevant - different AS local_pref: Some(100), @@ -464,11 +475,12 @@ mod test { // AS 300: one path with low MED let as300_path = Path { nexthop: ip4, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 300, - peer: ip4, + peer: PeerId::Ip(ip4), id: 3, med: Some(10), // Low MED, but can't "steal" selection from other ASes local_pref: Some(100), @@ -530,11 +542,12 @@ mod test { // Three paths from AS 100, all with same MED let path1 = Path { nexthop: ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 100, - peer: ip1, + peer: PeerId::Ip(ip1), id: 1, med: Some(50), local_pref: Some(100), @@ -546,11 +559,12 @@ mod test { let path2 = Path { nexthop: ip2, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 100, - peer: ip2, + peer: PeerId::Ip(ip2), id: 1, med: Some(50), // Same MED local_pref: Some(100), @@ -562,11 +576,12 @@ mod test { let path3 = Path { nexthop: ip3, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 100, - peer: ip3, + peer: PeerId::Ip(ip3), id: 1, med: Some(50), // Same MED local_pref: Some(100), diff --git a/rdb/src/db.rs b/rdb/src/db.rs index 6738479b..76f5a814 100644 --- a/rdb/src/db.rs +++ b/rdb/src/db.rs @@ -45,6 +45,10 @@ const BGP_ROUTER: &str = "bgp_router"; /// information. const BGP_NEIGHBOR: &str = "bgp_neighbor"; +/// The handle used to open a persistent key-value tree for BGP neighbor +/// information. +const BGP_UNNUMBERED_NEIGHBOR: &str = "bgp_unnumbered_neighbor"; + /// The handle used to open a persistent key-value tree for settings /// information. const SETTINGS: &str = "settings"; @@ -101,7 +105,7 @@ pub struct Db { /// A set of watchers that are notified when changes to the data store occur. watchers: Arc>>, - /// Reaps expired routes from the local RIB + /// Reaps expired routes from the local RIB. reaper: Arc, /// Switch slot reported from MGS. @@ -318,6 +322,28 @@ impl Db { Ok(()) } + pub fn add_unnumbered_bgp_neighbor( + &self, + nbr: BgpUnnumberedNeighborInfo, + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(BGP_UNNUMBERED_NEIGHBOR)?; + let key = nbr.interface.clone(); + let value = serde_json::to_string(&nbr)?; + tree.insert(key.as_str(), value.as_str())?; + tree.flush()?; + Ok(()) + } + + pub fn remove_unnumbered_bgp_neighbor( + &self, + interface: &str, + ) -> Result<(), Error> { + let tree = self.persistent.open_tree(BGP_UNNUMBERED_NEIGHBOR)?; + tree.remove(interface)?; + tree.flush()?; + Ok(()) + } + pub fn remove_bgp_neighbor(&self, addr: IpAddr) -> Result<(), Error> { let tree = self.persistent.open_tree(BGP_NEIGHBOR)?; let key = addr.to_string(); @@ -363,6 +389,45 @@ impl Db { Ok(result) } + pub fn get_unnumbered_bgp_neighbors( + &self, + ) -> Result, Error> { + let tree = self.persistent.open_tree(BGP_UNNUMBERED_NEIGHBOR)?; + let result = tree + .scan_prefix(vec![]) + .filter_map(|item| { + let (_key, value) = match item { + Ok(item) => item, + Err(ref e) => { + rdb_log!( + self, + error, + "error fetching unnumbered bgp neighbor entry {item:?}: {e}"; + "unit" => UNIT_PERSISTENT + ); + return None; + } + }; + let value = String::from_utf8_lossy(&value); + let value: BgpUnnumberedNeighborInfo = match serde_json::from_str(&value) + { + Ok(item) => item, + Err(ref e) => { + rdb_log!( + self, + error, + "error parsing unnumbered bgp neighbor entry value {value:?}: {e}"; + "unit" => UNIT_PERSISTENT + ); + return None; + } + }; + Some(value) + }) + .collect(); + Ok(result) + } + pub fn add_bfd_neighbor(&self, cfg: BfdPeerConfig) -> Result<(), Error> { let tree = self.persistent.open_tree(BFD_NEIGHBOR)?; let key = cfg.peer.to_string(); @@ -1139,7 +1204,7 @@ impl Db { } // for each route in @prefixes, remove all bgp paths learned from @peer - pub fn remove_bgp_prefixes(&self, prefixes: &[Prefix], peer: &IpAddr) { + pub fn remove_bgp_prefixes(&self, prefixes: &[Prefix], peer: &PeerId) { let mut pcn = PrefixChangeNotification::default(); self.remove_path_for_prefixes( prefixes, @@ -1154,7 +1219,7 @@ impl Db { // wrapper for remove_bgp_prefixes to handle the "all routes" corner case. // e.g. when peer is deleted or exits Established state - pub fn remove_bgp_prefixes_from_peer(&self, peer: &IpAddr) { + pub fn remove_bgp_prefixes_from_peer(&self, peer: &PeerId) { // TODO(ipv6): call this just for enabled address-families. // no need to walk the full rib for an AF that isn't affected let peer_routes4: Vec<_> = self @@ -1228,7 +1293,7 @@ impl Db { Ok(()) } - pub fn mark_bgp_peer_stale4(&self, peer: IpAddr) { + pub fn mark_bgp_peer_stale4(&self, peer: PeerId) { let mut rib = lock!(self.rib4_loc); rib.iter_mut().for_each(|(_prefix, path)| { let targets: Vec = path @@ -1250,7 +1315,7 @@ impl Db { }); } - pub fn mark_bgp_peer_stale6(&self, peer: IpAddr) { + pub fn mark_bgp_peer_stale6(&self, peer: PeerId) { let mut rib = lock!(self.rib6_loc); rib.iter_mut().for_each(|(_prefix, path)| { let targets: Vec = path @@ -1287,9 +1352,9 @@ impl Db { *value = slot; } - pub fn mark_bgp_peer_stale(&self, peer: IpAddr, af: AddressFamily) { + pub fn mark_bgp_peer_stale(&self, peer: PeerId, af: AddressFamily) { match af { - AddressFamily::Ipv4 => self.mark_bgp_peer_stale4(peer), + AddressFamily::Ipv4 => self.mark_bgp_peer_stale4(peer.clone()), AddressFamily::Ipv6 => self.mark_bgp_peer_stale6(peer), } } @@ -1388,7 +1453,7 @@ mod test { use crate::StaticRouteKey; use crate::{ BgpPathProperties, DEFAULT_RIB_PRIORITY_BGP, - DEFAULT_RIB_PRIORITY_STATIC, Path, Prefix, Prefix4, db::Db, + DEFAULT_RIB_PRIORITY_STATIC, Path, PeerId, Prefix, Prefix4, db::Db, }; // init test vars let p0 = Prefix::from("192.168.0.0/24".parse::().unwrap()); @@ -1400,11 +1465,12 @@ mod test { let bgp_path0 = Path { nexthop: remote_ip0, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 1111, - peer: remote_ip0, + peer: PeerId::Ip(remote_ip0), id: 1111, med: Some(1111), local_pref: Some(1111), @@ -1415,11 +1481,12 @@ mod test { }; let bgp_path1 = Path { nexthop: remote_ip1, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 2222, - peer: remote_ip1, + peer: PeerId::Ip(remote_ip1), id: 2222, med: Some(2222), local_pref: Some(2222), @@ -1435,11 +1502,12 @@ mod test { // BESTPATH_FANOUT is increased to test ECMP. let bgp_path2 = Path { nexthop: remote_ip2, + nexthop_interface: None, rib_priority: DEFAULT_RIB_PRIORITY_BGP, shutdown: false, bgp: Some(BgpPathProperties { origin_as: 2222, - peer: remote_ip2, + peer: PeerId::Ip(remote_ip2), id: 2222, med: Some(2222), local_pref: Some(4444), diff --git a/rdb/src/proptest.rs b/rdb/src/proptest.rs index c9ee02c7..a4bba03f 100644 --- a/rdb/src/proptest.rs +++ b/rdb/src/proptest.rs @@ -8,9 +8,12 @@ //! correctness and consistency of prefix operations (excluding wire format //! tests, which are in bgp/src/proptest.rs since they test BgpWireFormat). -use crate::types::{ - BgpNeighborInfo, ImportExportPolicy4, ImportExportPolicy6, Prefix, Prefix4, - Prefix6, StaticRouteKey, +use crate::{ + BgpNeighborParameters, + types::{ + BgpNeighborInfo, ImportExportPolicy4, ImportExportPolicy6, Prefix, + Prefix4, Prefix6, StaticRouteKey, + }, }; use proptest::{prelude::*, strategy::Just}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; @@ -168,30 +171,32 @@ fn bgp_neighbor_info_strategy() -> impl Strategy { asn, name, host, - hold_time: 90, - idle_hold_time: 60, - delay_open: 0, - connect_retry: 30, - keepalive: 30, - resolution: 1000, group: "test".into(), - passive: false, - remote_asn: Some(65001), - min_ttl: Some(1), - md5_auth_key: Some("password".to_string()), - multi_exit_discriminator: Some(100), - communities: vec![], - local_pref: Some(100), - enforce_first_as: false, - ipv4_enabled: true, - ipv6_enabled: true, - allow_import4, - allow_export4, - allow_import6, - allow_export6, - nexthop4, - nexthop6, - vlan_id: Some(1), + parameters: BgpNeighborParameters { + hold_time: 90, + idle_hold_time: 60, + delay_open: 0, + connect_retry: 30, + keepalive: 30, + resolution: 1000, + passive: false, + remote_asn: Some(65001), + min_ttl: Some(1), + md5_auth_key: Some("password".to_string()), + multi_exit_discriminator: Some(100), + communities: vec![], + local_pref: Some(100), + enforce_first_as: false, + ipv4_enabled: true, + ipv6_enabled: true, + allow_import4, + allow_export4, + allow_import6, + allow_export6, + nexthop4, + nexthop6, + vlan_id: Some(1), + }, } }, ) @@ -470,47 +475,47 @@ proptest! { "Host should survive serialization round-trip" ); prop_assert_eq!( - deserialized.nexthop4, neighbor.nexthop4, + deserialized.parameters.nexthop4, neighbor.parameters.nexthop4, "IPv4 nexthop should survive serialization round-trip" ); prop_assert_eq!( - deserialized.nexthop6, neighbor.nexthop6, + deserialized.parameters.nexthop6, neighbor.parameters.nexthop6, "IPv6 nexthop should survive serialization round-trip" ); prop_assert_eq!( - deserialized.ipv4_enabled, neighbor.ipv4_enabled, + deserialized.parameters.ipv4_enabled, neighbor.parameters.ipv4_enabled, "IPv4 enabled flag should survive serialization round-trip" ); prop_assert_eq!( - deserialized.ipv6_enabled, neighbor.ipv6_enabled, + deserialized.parameters.ipv6_enabled, neighbor.parameters.ipv6_enabled, "IPv6 enabled flag should survive serialization round-trip" ); prop_assert_eq!( - deserialized.multi_exit_discriminator, neighbor.multi_exit_discriminator, + deserialized.parameters.multi_exit_discriminator, neighbor.parameters.multi_exit_discriminator, "MED should survive serialization round-trip" ); prop_assert_eq!( - deserialized.local_pref, neighbor.local_pref, + deserialized.parameters.local_pref, neighbor.parameters.local_pref, "Local preference should survive serialization round-trip" ); prop_assert_eq!( - deserialized.remote_asn, neighbor.remote_asn, + deserialized.parameters.remote_asn, neighbor.parameters.remote_asn, "Remote ASN should survive serialization round-trip" ); prop_assert_eq!( - deserialized.allow_import4, neighbor.allow_import4, + deserialized.parameters.allow_import4, neighbor.parameters.allow_import4, "IPv4 import policy should survive serialization round-trip" ); prop_assert_eq!( - deserialized.allow_export4, neighbor.allow_export4, + deserialized.parameters.allow_export4, neighbor.parameters.allow_export4, "IPv4 export policy should survive serialization round-trip" ); prop_assert_eq!( - deserialized.allow_import6, neighbor.allow_import6, + deserialized.parameters.allow_import6, neighbor.parameters.allow_import6, "IPv6 import policy should survive serialization round-trip" ); prop_assert_eq!( - deserialized.allow_export6, neighbor.allow_export6, + deserialized.parameters.allow_export6, neighbor.parameters.allow_export6, "IPv6 export policy should survive serialization round-trip" ); } diff --git a/rdb/src/types.rs b/rdb/src/types.rs index 3454d694..94fe1010 100644 --- a/rdb/src/types.rs +++ b/rdb/src/types.rs @@ -16,7 +16,9 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::str::FromStr; // Re-export core types from rdb-types -pub use rdb_types::{AddressFamily, Prefix, Prefix4, Prefix6, ProtocolFilter}; +pub use rdb_types::{ + AddressFamily, PeerId, Prefix, Prefix4, Prefix6, ProtocolFilter, +}; // Marker types for compile-time address family discrimination. // @@ -44,9 +46,53 @@ pub struct Ipv4Marker; #[derive(Clone, Copy, Debug)] pub struct Ipv6Marker; +/// Pre-UNNUMBERED version of Path (uses BgpPathPropertiesV1). +/// Used for API versions before VERSION_UNNUMBERED (5.0.0). +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + JsonSchema, + Eq, + PartialEq, + PartialOrd, + Ord, +)] +#[schemars(rename = "Path")] +pub struct PathV1 { + pub nexthop: IpAddr, + pub shutdown: bool, + pub rib_priority: u8, + pub bgp: Option, + pub vlan_id: Option, +} + +impl From for PathV1 { + fn from(value: Path) -> Self { + Self { + nexthop: value.nexthop, + shutdown: value.shutdown, + rib_priority: value.rib_priority, + bgp: value.bgp.map(BgpPathPropertiesV1::from), + vlan_id: value.vlan_id, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] pub struct Path { pub nexthop: IpAddr, + + /// Interface binding for nexthop resolution. + /// + /// This field is only populated for BGP unnumbered sessions where the nexthop + /// is a link-local IPv6 address. For numbered peers, this is always None. + /// + /// Added in API version 5.0.0 (UNNUMBERED). + #[serde(skip_serializing_if = "Option::is_none", default)] + pub nexthop_interface: Option, + pub shutdown: bool, pub rib_priority: u8, pub bgp: Option, @@ -71,10 +117,11 @@ impl Ord for Path { return Ordering::Equal; } - // Static paths: identified by (nexthop, vlan_id) + // Static paths: identified by (nexthop, nexthop_interface, vlan_id) if self.bgp.is_none() && other.bgp.is_none() && self.nexthop == other.nexthop + && self.nexthop_interface == other.nexthop_interface && self.vlan_id == other.vlan_id { return Ordering::Equal; @@ -97,6 +144,7 @@ impl From for Path { fn from(value: StaticRouteKey) -> Self { Self { nexthop: value.nexthop, + nexthop_interface: None, // Static routes don't use interface binding vlan_id: value.vlan_id, rib_priority: value.rib_priority, shutdown: false, @@ -105,11 +153,61 @@ impl From for Path { } } +/// Pre-UNNUMBERED version of BgpPathProperties (peer is IpAddr). +/// Used for API versions before VERSION_UNNUMBERED (5.0.0). +#[derive( + Debug, + Clone, + Serialize, + Deserialize, + JsonSchema, + Eq, + PartialEq, + PartialOrd, + Ord, +)] +#[schemars(rename = "BgpPathProperties")] +pub struct BgpPathPropertiesV1 { + pub origin_as: u32, + pub id: u32, + pub peer: IpAddr, + pub med: Option, + pub local_pref: Option, + pub as_path: Vec, + pub stale: Option>, +} + +impl From for BgpPathPropertiesV1 { + fn from(value: BgpPathProperties) -> Self { + Self { + origin_as: value.origin_as, + id: value.id, + // Convert PeerId to IpAddr - only Ip variant is valid for V1 API + peer: match value.peer { + PeerId::Ip(ip) => ip, + PeerId::Interface(iface) => { + // This shouldn't happen in pre-UNNUMBERED versions + // Log warning and use unspecified address as fallback + eprintln!( + "Warning: Interface peer '{}' in V1 API context", + iface + ); + IpAddr::V6(Ipv6Addr::UNSPECIFIED) + } + }, + med: value.med, + local_pref: value.local_pref, + as_path: value.as_path, + stale: value.stale, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Eq, PartialEq)] pub struct BgpPathProperties { pub origin_as: u32, pub id: u32, - pub peer: IpAddr, + pub peer: PeerId, pub med: Option, pub local_pref: Option, pub as_path: Vec, @@ -527,14 +625,29 @@ pub enum ImportExportPolicy { pub struct BgpNeighborInfo { pub asn: u32, pub name: String, + pub group: String, pub host: SocketAddr, + pub parameters: BgpNeighborParameters, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +pub struct BgpUnnumberedNeighborInfo { + pub asn: u32, + pub name: String, + pub group: String, + pub interface: String, + pub router_lifetime: u16, + pub parameters: BgpNeighborParameters, +} + +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)] +pub struct BgpNeighborParameters { pub hold_time: u64, pub idle_hold_time: u64, pub delay_open: u64, pub connect_retry: u64, pub keepalive: u64, pub resolution: u64, - pub group: String, pub passive: bool, pub remote_asn: Option, pub min_ttl: Option,