Policy scripts: the operator's own rules for routes, in Rhai
A new crate, bgp-policy, runs functions of a script on routes: on their
way in, after the general checks of a peer's import and before the
checks that can change (RPKI, next hop reachability); and on their way
out to a peer. A function is given the route and the peer, may refuse
the route, and may change its communities, large communities,
LOCAL_PREF and MED, and on the way out prepend our AS:
fn from_peer(route, peer) {
if route.has_community("65535:666") { return false; }
route.add_community(LEARNED_FROM[peer.role]);
route.local_pref = PREFERENCE[peer.role];
true
}
Configuration: policy { path | source, import_function, export_function }
and, for a peer, import { function } and export { function }. The script
is compiled at startup; `bgp --check` reads it and says which function
is whose. A function that fails on a route has refused it. Scripts have
no files, network or clock, and are stopped after 200,000 steps.
Route selection now goes by a LOCAL_PREF on a route from an external
peer: the decoder never lets a peer's own through, so one that is there
was set by policy.
What it costs was measured before it was built on (benches/policy.rs):
a call that tests a few communities and changes two takes 1 to 3
microseconds, some two hundred times what Rust would. So a function is
asked once for the routes of an UPDATE, which share their attributes,
unless it asks for the prefix. One peer sending 1.23 M routes over a
session: 0.37 s without a function, 0.80 s with one that marks and
ranks every route, 3.4 s if it also looks at the prefix.
Export applies to what is announced, which today is what the daemon
originates; learned routes are not passed on yet.
Tested by unit tests of the crate, sessions end to end, a fuzz target on
the example script (testdata/policy.rhai), and a lab phase in which a
script changes which of FRR's and BIRD's route is chosen and what each
of them is told.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f1d80c66df
commit
cbf0df1eef
52 files changed
+3467
-43
No files matched your search
@@ -69,7 +69,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
RUSTFLAGS: ""
|
RUSTFLAGS: ""
|
||||||
run: |
|
run: |
|
||||||
for target in message message_body attrs prefix_text config rtr netlink; do
|
for target in message message_body attrs policy prefix_text config rtr netlink; do
|
||||||
mkdir -p "corpus/$target"
|
mkdir -p "corpus/$target"
|
||||||
cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60
|
cargo fuzz run "$target" "corpus/$target" "seeds/$target" -- -max_total_time=60
|
||||||
done
|
done
|
||||||
Generated
+169
-2
@@ -8,6 +8,19 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ahash"
|
||||||
|
version = "0.8.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"const-random",
|
||||||
|
"once_cell",
|
||||||
|
"version_check",
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -159,6 +172,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|||||||
name = "bgp"
|
name = "bgp"
|
||||||
version = "0.0.2"
|
version = "0.0.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bgp-policy",
|
||||||
"bgp-server",
|
"bgp-server",
|
||||||
"bgp-web",
|
"bgp-web",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -175,6 +189,7 @@ version = "0.0.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bgp-integration-tests",
|
"bgp-integration-tests",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
|
"bgp-policy",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
"bgp-server",
|
"bgp-server",
|
||||||
@@ -210,6 +225,7 @@ version = "0.0.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bgp-fib",
|
"bgp-fib",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
|
"bgp-policy",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
"bgp-rpki",
|
"bgp-rpki",
|
||||||
@@ -239,6 +255,18 @@ dependencies = [
|
|||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bgp-policy"
|
||||||
|
version = "0.0.2"
|
||||||
|
dependencies = [
|
||||||
|
"bgp-packet",
|
||||||
|
"bgp-primitives",
|
||||||
|
"foldhash",
|
||||||
|
"rhai",
|
||||||
|
"thiserror 2.0.12",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bgp-primitives"
|
name = "bgp-primitives"
|
||||||
version = "0.0.2"
|
version = "0.0.2"
|
||||||
@@ -285,6 +313,7 @@ version = "0.0.2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bgp-fib",
|
"bgp-fib",
|
||||||
"bgp-packet",
|
"bgp-packet",
|
||||||
|
"bgp-policy",
|
||||||
"bgp-primitives",
|
"bgp-primitives",
|
||||||
"bgp-rib",
|
"bgp-rib",
|
||||||
"bgp-rpki",
|
"bgp-rpki",
|
||||||
@@ -431,6 +460,26 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||||
|
dependencies = [
|
||||||
|
"const-random-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-random-macro"
|
||||||
|
version = "0.1.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
"once_cell",
|
||||||
|
"tiny-keccak",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.2"
|
version = "1.5.2"
|
||||||
@@ -440,6 +489,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.7"
|
version = "0.2.7"
|
||||||
@@ -609,6 +664,17 @@ dependencies = [
|
|||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "getrandom"
|
name = "getrandom"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -1020,6 +1086,15 @@ dependencies = [
|
|||||||
"memoffset",
|
"memoffset",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "no-std-compat"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
|
||||||
|
dependencies = [
|
||||||
|
"spin",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -1052,6 +1127,9 @@ name = "once_cell"
|
|||||||
version = "1.21.1"
|
version = "1.21.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
|
checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc"
|
||||||
|
dependencies = [
|
||||||
|
"portable-atomic",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
@@ -1094,6 +1172,12 @@ version = "0.2.16"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "portable-atomic"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -1253,7 +1337,7 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom",
|
"getrandom 0.3.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1334,6 +1418,35 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rhai"
|
||||||
|
version = "1.26.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
"bitflags",
|
||||||
|
"no-std-compat",
|
||||||
|
"num-traits",
|
||||||
|
"once_cell",
|
||||||
|
"rhai_codegen",
|
||||||
|
"smallvec",
|
||||||
|
"smartstring",
|
||||||
|
"thin-vec",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rhai_codegen"
|
||||||
|
version = "3.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.100",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "0.38.44"
|
version = "0.38.44"
|
||||||
@@ -1503,6 +1616,17 @@ version = "1.14.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
|
checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smartstring"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
"static_assertions",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
version = "0.6.5"
|
version = "0.6.5"
|
||||||
@@ -1513,12 +1637,24 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stats_alloc"
|
name = "stats_alloc"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -1602,12 +1738,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom",
|
"getrandom 0.3.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.2",
|
"rustix 1.1.2",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thin-vec"
|
||||||
|
version = "0.2.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@@ -1657,6 +1799,15 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny-keccak"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||||
|
dependencies = [
|
||||||
|
"crunchy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -1876,6 +2027,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -1974,6 +2131,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-time"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "which"
|
name = "which"
|
||||||
version = "4.4.2"
|
version = "4.4.2"
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ unimplemented = "warn"
|
|||||||
# --- Our crates ---
|
# --- Our crates ---
|
||||||
bgp-fib = { path = "crates/fib" }
|
bgp-fib = { path = "crates/fib" }
|
||||||
bgp-packet = { path = "crates/packet" }
|
bgp-packet = { path = "crates/packet" }
|
||||||
|
bgp-policy = { path = "crates/policy" }
|
||||||
bgp-primitives = { path = "crates/primitives" }
|
bgp-primitives = { path = "crates/primitives" }
|
||||||
bgp-rib = { path = "crates/rib" }
|
bgp-rib = { path = "crates/rib" }
|
||||||
bgp-rpki = { path = "crates/rpki" }
|
bgp-rpki = { path = "crates/rpki" }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ It has not yet run in a production network.
|
|||||||
|
|
||||||
### Not there yet
|
### Not there yet
|
||||||
|
|
||||||
- Announcing learned routes to other peers, and an export policy for it
|
- Announcing learned routes to other peers (the export policy for it is there: scripts)
|
||||||
- TCP-AO / TCP MD5 and GTSM
|
- TCP-AO / TCP MD5 and GTSM
|
||||||
- Graceful restart, BFD, changing the configuration without a restart
|
- Graceful restart, BFD, changing the configuration without a restart
|
||||||
- Next hops that are not directly connected
|
- Next hops that are not directly connected
|
||||||
@@ -30,6 +30,7 @@ It has not yet run in a production network.
|
|||||||
| `crates/rib` | The routing table, best route selection, the journal of changes |
|
| `crates/rib` | The routing table, best route selection, the journal of changes |
|
||||||
| `crates/rpki` | The RTR client; origin validation and ASPA path verification |
|
| `crates/rpki` | The RTR client; origin validation and ASPA path verification |
|
||||||
| `crates/fib` | The best routes in the Linux kernel, over netlink |
|
| `crates/fib` | The best routes in the Linux kernel, over netlink |
|
||||||
|
| `crates/policy` | The operator's own rules for routes, as Rhai scripts |
|
||||||
| `crates/server` | Sessions, the state machine, policy, configuration |
|
| `crates/server` | Sessions, the state machine, policy, configuration |
|
||||||
| `crates/web` | The status page, HTTP API and metrics |
|
| `crates/web` | The status page, HTTP API and metrics |
|
||||||
| `bin/bgp` | The daemon |
|
| `bin/bgp` | The daemon |
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ repository.workspace = true
|
|||||||
version.workspace = true
|
version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bgp-policy.workspace = true
|
||||||
bgp-server.workspace = true
|
bgp-server.workspace = true
|
||||||
bgp-web.workspace = true
|
bgp-web.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
|
|||||||
+117
-2
@@ -42,8 +42,9 @@ connections, with RFC 4271 collision detection), announces its originated
|
|||||||
routes to every peer with itself as next hop, judges what peers send by each
|
routes to every peer with itself as next hop, judges what peers send by each
|
||||||
peer's import policy, keeps what passes, chooses a best route for each
|
peer's import policy, keeps what passes, chooses a best route for each
|
||||||
prefix, and on Linux can put those best routes in the kernel so that the
|
prefix, and on Linux can put those best routes in the kernel so that the
|
||||||
machine forwards by them. It does not yet pass routes learned from one peer
|
machine forwards by them. The operator's own rules for what is taken, how
|
||||||
on to another, and has no export policy.
|
it is marked and ranked, and what each peer is told are a script. It does
|
||||||
|
not yet pass routes learned from one peer on to another.
|
||||||
|
|
||||||
## Who may connect
|
## Who may connect
|
||||||
|
|
||||||
@@ -89,6 +90,112 @@ A refused route is dropped, along with the route it would have replaced.
|
|||||||
`bgp_routes_rejected_total` counts them by reason, and the status page shows
|
`bgp_routes_rejected_total` counts them by reason, and the status page shows
|
||||||
how many routes each peer has and how many were refused.
|
how many routes each peer has and how many were refused.
|
||||||
|
|
||||||
|
## Policy scripts
|
||||||
|
|
||||||
|
The checks above say what no network should take from another. What this
|
||||||
|
network wants beyond that (which routes it prefers, what it marks them
|
||||||
|
with, what it tells which neighbor) is said in a script, in
|
||||||
|
[Rhai](https://rhai.rs). A function is given a route and the peer it comes
|
||||||
|
from or goes to, may change the route, and returns whether it is taken:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const BLACKHOLE = "65535:666";
|
||||||
|
const LEARNED_FROM = #{ customer: "64601:1000", peer: "64601:2000", provider: "64601:3000" };
|
||||||
|
const PREFERENCE = #{ customer: 300, peer: 200, provider: 100 };
|
||||||
|
|
||||||
|
// Routes on their way in.
|
||||||
|
fn from_peer(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
route.remove_community("64601:*"); // nobody else's word for our marks
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]); // where we learned it
|
||||||
|
route.local_pref = PREFERENCE[peer.role];
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes on their way out.
|
||||||
|
fn to_peer(route, peer) {
|
||||||
|
if route.has_community("64601:3000") && peer.role != "customer" { return false; }
|
||||||
|
if peer.name == "backup-transit" { route.prepend(2); }
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
policy { path: "policy.rhai" } # beside the configuration file
|
||||||
|
peers {
|
||||||
|
name: "transit-a" ...
|
||||||
|
role: PROVIDER
|
||||||
|
import { function: "from_peer" } # after every check of `import`
|
||||||
|
export { function: "to_peer" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`policy { import_function: ... export_function: ... }` names the functions
|
||||||
|
for peers that name none. `testdata/policy.rhai` is a longer example.
|
||||||
|
|
||||||
|
**What a function is given.**
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `route.prefix`, `route.prefix_len`, `route.family` | `"203.0.113.0/24"`, `24`, `"ipv4"` |
|
||||||
|
| `route.within("203.0.112.0/22")` | whether the prefix is that one or inside it |
|
||||||
|
| `route.as_path`, `route.as_path_len` | `[64602, 3356, 15169]`, `3` |
|
||||||
|
| `route.origin_as`, `route.neighbor_as`, `route.path_has(3356)` | the last and first AS of the path; whether an AS is on it |
|
||||||
|
| `route.origin`, `route.next_hop` | `"igp"`, `"egp"` or `"incomplete"`; `"192.0.2.1"` |
|
||||||
|
| `route.communities`, `route.large_communities` | `["3356:3", ...]`, `["64601:1:2", ...]` |
|
||||||
|
| `route.has_community("3356:3")` | also `(3356, 3)`, and `"3356:*"` for any of that AS |
|
||||||
|
| `route.add_community(..)`, `route.remove_community("3356:*")` | `"*:*"` removes all |
|
||||||
|
| `route.has_large_community(..)`, `add_large_community(..)`, `remove_large_community(..)` | the same, as `"64601:1:2"` |
|
||||||
|
| `route.local_pref`, `route.med` | the number, or `()` for none; set with `route.local_pref = 200`, unset with `= ()` |
|
||||||
|
| `route.prepend(2)` | on the way out: our AS that many more times on the path |
|
||||||
|
| `peer.name`, `peer.asn`, `peer.address`, `peer.ibgp`, `peer.local_asn` | |
|
||||||
|
| `peer.role` | `"customer"`, `"provider"`, `"peer"`, `"route_server"`, `"route_server_client"`, or `""` |
|
||||||
|
|
||||||
|
**How it fits in.**
|
||||||
|
|
||||||
|
- On the way in, a function is asked after every check of the peer's
|
||||||
|
`import` has passed, and before the checks whose answer can change (the
|
||||||
|
RPKI's, and whether the next hop can be reached). A route it refuses is
|
||||||
|
counted as "refused by policy function". What it changes is what goes
|
||||||
|
into the table: a `local_pref` it sets is what route selection goes by.
|
||||||
|
- On the way out it is asked about every route the peer would be sent. The
|
||||||
|
daemon announces the routes it originates; it does not yet pass on what
|
||||||
|
it learned from other peers. When it does, those routes will go through
|
||||||
|
the same function, with the communities they were given on the way in.
|
||||||
|
LOCAL_PREF does not leave the AS whatever a function sets.
|
||||||
|
- The script is read when the daemon starts. One that does not parse, or a
|
||||||
|
function that is named and not there, stops it from starting. `bgp
|
||||||
|
--check config.textpb` reads the script and says which function is
|
||||||
|
whose. Changing the script takes a restart.
|
||||||
|
- A function that fails on a route (it asks for something that is not
|
||||||
|
there, or does not end) has refused that route: failing is not a way
|
||||||
|
through. `bgp_policy_failures_total{function}` counts these, and the log
|
||||||
|
says why, at most once a minute.
|
||||||
|
- A script can only compute. It has no files, no network and no clock, and
|
||||||
|
a function is stopped after 200,000 steps.
|
||||||
|
|
||||||
|
**What it costs.** Rhai walks the script's syntax tree; a call like the
|
||||||
|
ones above takes one to three microseconds, where the general checks take
|
||||||
|
a fraction of one. So a function is not asked about every route. Routes
|
||||||
|
that arrive in one UPDATE share their attributes, and a function that does
|
||||||
|
not ask for the prefix (`prefix`, `prefix_len`, `within`) gives one answer
|
||||||
|
for all of them. One that does is asked about each. With one peer sending
|
||||||
|
a table of 1.23 million routes over a session (`cargo bench -p bgp-bench
|
||||||
|
--bench policy`, on a laptop):
|
||||||
|
|
||||||
|
| | Time until all are in the table |
|
||||||
|
|---|---|
|
||||||
|
| no function | 0.37 s |
|
||||||
|
| a function that does nothing | 0.51 s |
|
||||||
|
| one that marks and ranks every route | 0.80 s |
|
||||||
|
| the same, and it looks at the prefix | 3.4 s |
|
||||||
|
|
||||||
|
So: filter by prefix with `import { prefixes ... }` and the length limits,
|
||||||
|
which cost next to nothing, and keep scripts to what routes carry. A
|
||||||
|
function that has to look at the prefix still takes in a full table in a
|
||||||
|
few seconds.
|
||||||
|
|
||||||
## Route origin validation (RPKI)
|
## Route origin validation (RPKI)
|
||||||
|
|
||||||
With `rpki { caches { address: "..." port: 3323 } }` in the configuration,
|
With `rpki { caches { address: "..." port: 3323 } }` in the configuration,
|
||||||
@@ -369,6 +476,7 @@ Routinator 0.15.2. What it covers:
|
|||||||
| `limits` | A peer whose role does not fit gets no session; a peer over its prefix limit gets Cease / Maximum Number of Prefixes and is held down |
|
| `limits` | A peer whose role does not fit gets no session; a peer over its prefix limit gets Cease / Maximum Number of Prefixes and is held down |
|
||||||
| `hijacks` | FRR announces our own prefix, a route whose next hop is BIRD, and the exchange LAN's own prefix: none is taken |
|
| `hijacks` | FRR announces our own prefix, a route whose next hop is BIRD, and the exchange LAN's own prefix: none is taken |
|
||||||
| `nexthops` | A better route from FRR whose next hop cannot be reached is held and BIRD's is chosen and installed; they swap when the next hop becomes a neighbor, and swap back when it stops being one |
|
| `nexthops` | A better route from FRR whose next hop cannot be reached is held and BIRD's is chosen and installed; they swap when the next hop becomes a neighbor, and swap back when it stops being one |
|
||||||
|
| `policy` | A policy script: routes marked with where they were learned and ranked by it, which changes which of FRR's and BIRD's route is chosen and installed; FRR told our route with a longer path and a mark, BIRD not told it |
|
||||||
| `v6only` | The exchange LAN without IPv4 addresses: one IPv6 session with each router for both families, IPv4 routes with IPv6 next hops (RFC 8950) in BGP and in the kernel, IPv4 pings across |
|
| `v6only` | The exchange LAN without IPv4 addresses: one IPv6 session with each router for both families, IPv4 routes with IPv6 next hops (RFC 8950) in BGP and in the kernel, IPv4 pings across |
|
||||||
| `linklocal` | The exchange LAN with link-local addresses only, and the same ones on a second interface: sessions with FRR (by interface, with radvd beside the daemon) and BIRD at `fe80::…%ix0`, both families in BGP and in the kernel, pings across, the interface taken down and up |
|
| `linklocal` | The exchange LAN with link-local addresses only, and the same ones on a second interface: sessions with FRR (by interface, with radvd beside the daemon) and BIRD at `fe80::…%ix0`, both families in BGP and in the kernel, pings across, the interface taken down and up |
|
||||||
|
|
||||||
@@ -416,6 +524,8 @@ For alerting, `/metricz` has:
|
|||||||
| `bgp_peer_max_prefixes{peer}` | The peer's limit, where it has one |
|
| `bgp_peer_max_prefixes{peer}` | The peer's limit, where it has one |
|
||||||
| `bgp_prefix_limit_exceeded_total{peer}` | Sessions ended for going over the limit |
|
| `bgp_prefix_limit_exceeded_total{peer}` | Sessions ended for going over the limit |
|
||||||
| `bgp_connections_refused_total{reason}` | Connections closed without a session |
|
| `bgp_connections_refused_total{reason}` | Connections closed without a session |
|
||||||
|
| `bgp_policy_runs_total{direction}` | Times a policy function was asked about a route |
|
||||||
|
| `bgp_policy_failures_total{function}` | Times one failed, which refuses the route |
|
||||||
| `bgp_peer_up{peer}` | 1 while the session with a configured peer is Established, else 0 |
|
| `bgp_peer_up{peer}` | 1 while the session with a configured peer is Established, else 0 |
|
||||||
| `bgp_build_info{version}` | Always 1; says which version is running |
|
| `bgp_build_info{version}` | Always 1; says which version is running |
|
||||||
|
|
||||||
@@ -454,6 +564,11 @@ groups:
|
|||||||
- alert: BgpHijackAttempt
|
- alert: BgpHijackAttempt
|
||||||
expr: increase(bgp_routes_rejected_total{reason=~"prefix is our own|next hop is not the peer"}[10m]) > 0
|
expr: increase(bgp_routes_rejected_total{reason=~"prefix is our own|next hop is not the peer"}[10m]) > 0
|
||||||
labels: { severity: page }
|
labels: { severity: page }
|
||||||
|
# A function of the policy script is failing: every route it fails
|
||||||
|
# on is refused.
|
||||||
|
- alert: BgpPolicyFunctionFailing
|
||||||
|
expr: increase(bgp_policy_failures_total[10m]) > 0
|
||||||
|
labels: { severity: page }
|
||||||
# The kernel is refusing routes, or its answers are being lost.
|
# The kernel is refusing routes, or its answers are being lost.
|
||||||
- alert: BgpKernelErrors
|
- alert: BgpKernelErrors
|
||||||
expr: increase(bgp_kernel_errors_total[10m]) > 0
|
expr: increase(bgp_kernel_errors_total[10m]) > 0
|
||||||
|
|||||||
+24
-2
@@ -1,4 +1,4 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use bgp_server::config::Config;
|
use bgp_server::config::Config;
|
||||||
@@ -39,7 +39,13 @@ async fn main() -> Result<()> {
|
|||||||
.wrap_err_with(|| format!("Failed to read {}", args.config.display()))?;
|
.wrap_err_with(|| format!("Failed to read {}", args.config.display()))?;
|
||||||
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
|
let proto_config = protobuf::text_format::parse_from_str::<BgpConfig>(&config_string)
|
||||||
.wrap_err("Failed to parse config file")?;
|
.wrap_err("Failed to parse config file")?;
|
||||||
let config = Config::from_proto(proto_config).wrap_err("Invalid configuration")?;
|
// What the configuration names by a relative path is beside it.
|
||||||
|
let beside = args
|
||||||
|
.config
|
||||||
|
.parent()
|
||||||
|
.filter(|directory| !directory.as_os_str().is_empty())
|
||||||
|
.unwrap_or(Path::new("."));
|
||||||
|
let config = Config::from_proto_in(proto_config, beside).wrap_err("Invalid configuration")?;
|
||||||
if args.check {
|
if args.check {
|
||||||
println!(
|
println!(
|
||||||
"{} is in order: AS{}, peers: {}, routes originated: {}",
|
"{} is in order: AS{}, peers: {}, routes originated: {}",
|
||||||
@@ -48,6 +54,22 @@ async fn main() -> Result<()> {
|
|||||||
config.peers.len(),
|
config.peers.len(),
|
||||||
config.originate.len()
|
config.originate.len()
|
||||||
);
|
);
|
||||||
|
if let Some(policy) = &config.policy {
|
||||||
|
println!("policy functions: {}", policy.functions().join(", "));
|
||||||
|
for peer in &config.peers {
|
||||||
|
let name = |hook: &Option<bgp_policy::Hook>| {
|
||||||
|
hook.as_ref()
|
||||||
|
.map_or("-", bgp_policy::Hook::name)
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {}: import {}, export {}",
|
||||||
|
peer.name,
|
||||||
|
name(&peer.import_hook),
|
||||||
|
name(&peer.export_hook)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ stats_alloc = { version = "0.1.10", optional = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
bgp-integration-tests.workspace = true
|
bgp-integration-tests.workspace = true
|
||||||
|
bgp-policy.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
bgp-server.workspace = true
|
bgp-server.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
@@ -38,5 +39,9 @@ harness = false
|
|||||||
name = "real_table"
|
name = "real_table"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "policy"
|
||||||
|
harness = false
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
//! What the operator's policy script costs, on a real table: the routes
|
||||||
|
//! one peer of a RIPE RIS route collector had announced to it, each with
|
||||||
|
//! the attributes it really has.
|
||||||
|
//!
|
||||||
|
//! Needs a dump in `crates/bench/data/` (see the README there) or at the
|
||||||
|
//! path in `BENCH_MRT`; without one it says so and does nothing.
|
||||||
|
//!
|
||||||
|
//! A function is asked about a route once for every UPDATE if it does not
|
||||||
|
//! look at the prefix, and once for every prefix if it does. How many
|
||||||
|
//! UPDATEs a table arrives in is the sender's business; the two ends are
|
||||||
|
//! one UPDATE for every distinct set of attributes, and one for every
|
||||||
|
//! route. Both are measured, with what the same work costs without a
|
||||||
|
//! script.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::hint::black_box;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
use bgp_bench::mrt::{Dump, parse_attributes};
|
||||||
|
use bgp_bench::{Prefixes, Shape, announcements, encoded};
|
||||||
|
use bgp_integration_tests::test_peer::TestPeer;
|
||||||
|
use bgp_integration_tests::test_server::TestServer;
|
||||||
|
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
||||||
|
use bgp_policy::{Direction, Hook, Peer, Policy, Subject};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bgp_server::config::PeerConfig;
|
||||||
|
use bgp_server::family::FamilySet;
|
||||||
|
use bgp_server::policy::ImportPolicy;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
const BLACKHOLE = "65535:666";
|
||||||
|
const LEARNED_FROM = #{ customer: "64601:1000", peer: "64601:2000", provider: "64601:3000" };
|
||||||
|
const PREFERENCE = #{ customer: 300, peer: 200, provider: 100 };
|
||||||
|
|
||||||
|
// Nothing at all: what asking costs.
|
||||||
|
fn nothing(route, peer) { true }
|
||||||
|
|
||||||
|
// Looks, and changes nothing.
|
||||||
|
fn filter(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
if route.as_path_len > 30 { return false; }
|
||||||
|
if route.path_has(64512) { return false; }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a network would have for its transit: refuse some, mark and
|
||||||
|
// rank the rest.
|
||||||
|
fn typical(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
if route.as_path_len > 30 { return false; }
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = PREFERENCE[peer.role];
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any peer, whatever it is to us.
|
||||||
|
fn marks(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
route.add_community("64601:3000");
|
||||||
|
route.local_pref = 100;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn marks_by_prefix(route, peer) {
|
||||||
|
if route.prefix_len > 24 && route.family == "ipv4" { return false; }
|
||||||
|
marks(route, peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same, and it looks at the prefix: asked for every route.
|
||||||
|
fn by_prefix(route, peer) {
|
||||||
|
if route.prefix_len > 24 && route.family == "ipv4" { return false; }
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = PREFERENCE[peer.role];
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn dump_path() -> Option<PathBuf> {
|
||||||
|
if let Ok(path) = std::env::var("BENCH_MRT") {
|
||||||
|
return Some(path.into());
|
||||||
|
}
|
||||||
|
let data = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data");
|
||||||
|
let mut dumps: Vec<PathBuf> = std::fs::read_dir(data)
|
||||||
|
.ok()?
|
||||||
|
.filter_map(|entry| Some(entry.ok()?.path()))
|
||||||
|
.filter(|path| path.extension().is_some_and(|e| e == "gz"))
|
||||||
|
.collect();
|
||||||
|
dumps.sort();
|
||||||
|
dumps.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attributes = (Vec<PathAttribute>, Option<NlriNextHop>);
|
||||||
|
|
||||||
|
struct Outcome {
|
||||||
|
elapsed: Duration,
|
||||||
|
asked: usize,
|
||||||
|
taken: usize,
|
||||||
|
changed: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks `hook` about each of `routes`.
|
||||||
|
fn ask<'a>(
|
||||||
|
hook: &Hook,
|
||||||
|
peer: &Peer,
|
||||||
|
routes: impl Iterator<Item = (&'a IpPrefix, &'a Attributes)>,
|
||||||
|
) -> Outcome {
|
||||||
|
let started = Instant::now();
|
||||||
|
let (mut asked, mut taken, mut changed) = (0, 0, 0);
|
||||||
|
for (prefix, (attributes, next_hop)) in routes {
|
||||||
|
let verdict = hook
|
||||||
|
.run(
|
||||||
|
peer,
|
||||||
|
&Subject {
|
||||||
|
prefix,
|
||||||
|
attributes,
|
||||||
|
next_hop: *next_hop,
|
||||||
|
direction: Direction::Import,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("the function runs");
|
||||||
|
asked += 1;
|
||||||
|
taken += usize::from(verdict.accept);
|
||||||
|
changed += usize::from(verdict.attributes.is_some());
|
||||||
|
black_box(&verdict);
|
||||||
|
}
|
||||||
|
Outcome {
|
||||||
|
elapsed: started.elapsed(),
|
||||||
|
asked,
|
||||||
|
taken,
|
||||||
|
changed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row(what: &str, outcome: &Outcome) {
|
||||||
|
println!(
|
||||||
|
"{what:<52} {:>8.3} s {:>7.0} ns each {} asked, {} taken, {} changed",
|
||||||
|
outcome.elapsed.as_secs_f64(),
|
||||||
|
outcome.elapsed.as_nanos() as f64 / outcome.asked.max(1) as f64,
|
||||||
|
outcome.asked,
|
||||||
|
outcome.taken,
|
||||||
|
outcome.changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One peer sends the whole (synthetic) table over TCP to a server that
|
||||||
|
/// gives its routes to `function`, or to none. Returns how long it took
|
||||||
|
/// for all of them to be in the table.
|
||||||
|
fn end_to_end(wire: &[BytesMut], routes: usize, function: Option<&str>) -> Duration {
|
||||||
|
const PEER_ASN: u32 = 64513;
|
||||||
|
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("a runtime");
|
||||||
|
runtime.block_on(async {
|
||||||
|
// The configured peer is whoever connects from this machine; the
|
||||||
|
// listener is where the server's own attempts to reach it go.
|
||||||
|
let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
|
||||||
|
.await
|
||||||
|
.expect("a port");
|
||||||
|
let endpoint = listener.local_addr().expect("its address");
|
||||||
|
let policy = Policy::compile(SCRIPT).expect("the script");
|
||||||
|
let import_hook = function.map(|name| policy.hook(name).expect("the function"));
|
||||||
|
let server = TestServer::start_with(4_200_000_000, |config| {
|
||||||
|
config.accept_unconfigured_peers = false;
|
||||||
|
config.peers = vec![PeerConfig {
|
||||||
|
name: "transit".into(),
|
||||||
|
endpoint: endpoint.into(),
|
||||||
|
remote_asn: PEER_ASN,
|
||||||
|
hold_time_secs: 90,
|
||||||
|
local_address: None,
|
||||||
|
families: FamilySet::supported(),
|
||||||
|
add_path: None,
|
||||||
|
import: ImportPolicy::permissive(),
|
||||||
|
role: None,
|
||||||
|
require_role: false,
|
||||||
|
import_hook,
|
||||||
|
export_hook: None,
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("a server");
|
||||||
|
let mut session = TestPeer::connect(server.addr).await.expect("a connection");
|
||||||
|
session
|
||||||
|
.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 1), 90)
|
||||||
|
.await
|
||||||
|
.expect("a session");
|
||||||
|
let stream: Vec<u8> = wire.iter().flat_map(|m| m.iter().copied()).collect();
|
||||||
|
let started = Instant::now();
|
||||||
|
let sending = tokio::spawn(async move {
|
||||||
|
session.send_raw(&stream).await.expect("the server reads");
|
||||||
|
session
|
||||||
|
});
|
||||||
|
while server.rib.len() < routes {
|
||||||
|
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||||
|
}
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
drop(sending.await);
|
||||||
|
elapsed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// The whole way, first: a session, its state machine, the general
|
||||||
|
// checks, the function, the table. On a table that is made up, so
|
||||||
|
// that it runs anywhere.
|
||||||
|
let shape = Shape::INTERNET;
|
||||||
|
let prefixes = Prefixes::generate(shape);
|
||||||
|
let wire = encoded(&announcements(&prefixes, shape, 1));
|
||||||
|
let routes = shape.prefixes();
|
||||||
|
println!(
|
||||||
|
"== End to end: one peer sends {routes} routes in {} UPDATEs ({:.1} routes each) over TCP",
|
||||||
|
wire.len(),
|
||||||
|
routes as f64 / wire.len() as f64
|
||||||
|
);
|
||||||
|
let mut without = Duration::ZERO;
|
||||||
|
for (what, function) in [
|
||||||
|
("no policy function", None),
|
||||||
|
("a function that does nothing", Some("nothing")),
|
||||||
|
("marks and ranks every route", Some("marks")),
|
||||||
|
("the same, and looks at the prefix", Some("marks_by_prefix")),
|
||||||
|
] {
|
||||||
|
// The better of two: the first run of all also warms the machine.
|
||||||
|
let elapsed = end_to_end(&wire, routes, function).min(end_to_end(&wire, routes, function));
|
||||||
|
if function.is_none() {
|
||||||
|
without = elapsed;
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"{what:<52} {:>8.3} s {:.2} M routes/s {:+.0}%",
|
||||||
|
elapsed.as_secs_f64(),
|
||||||
|
routes as f64 / elapsed.as_secs_f64() / 1e6,
|
||||||
|
100.0 * (elapsed.as_secs_f64() / without.as_secs_f64() - 1.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let Some(path) = dump_path() else {
|
||||||
|
println!("No table dump found; see crates/bench/data/README.md. Nothing more to do.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
println!("== Reading {}", path.display());
|
||||||
|
let dump = Dump::read(&path).expect("a readable dump");
|
||||||
|
let peer_index = dump.largest_peers(1)[0];
|
||||||
|
let routes = &dump.routes[peer_index];
|
||||||
|
let parsed: Vec<Option<Attributes>> = dump
|
||||||
|
.attribute_sets
|
||||||
|
.iter()
|
||||||
|
.map(|wire| parse_attributes(wire))
|
||||||
|
.collect();
|
||||||
|
let routes: Vec<(&IpPrefix, &Attributes)> = routes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(prefix, set)| Some((prefix, parsed[*set as usize].as_ref()?)))
|
||||||
|
.collect();
|
||||||
|
let distinct: HashSet<*const Attributes> = routes
|
||||||
|
.iter()
|
||||||
|
.map(|(_, attributes)| std::ptr::from_ref(*attributes))
|
||||||
|
.collect();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let once_per_set: Vec<(&IpPrefix, &Attributes)> = routes
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, attributes)| seen.insert(std::ptr::from_ref(*attributes)))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
let communities: usize = routes
|
||||||
|
.iter()
|
||||||
|
.map(|(_, (attributes, _))| {
|
||||||
|
attributes
|
||||||
|
.iter()
|
||||||
|
.map(|attribute| match attribute {
|
||||||
|
PathAttribute::Communities(communities) => communities.0.len(),
|
||||||
|
_ => 0,
|
||||||
|
})
|
||||||
|
.sum::<usize>()
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
println!(
|
||||||
|
"The peer with the most routes, AS{}: {} routes with {} distinct sets of attributes \
|
||||||
|
({:.1} routes a set), {:.1} communities a route\n",
|
||||||
|
dump.peers[peer_index].asn,
|
||||||
|
routes.len(),
|
||||||
|
distinct.len(),
|
||||||
|
routes.len() as f64 / distinct.len() as f64,
|
||||||
|
communities as f64 / routes.len() as f64
|
||||||
|
);
|
||||||
|
|
||||||
|
let policy = Policy::compile(SCRIPT).expect("the script");
|
||||||
|
let peer = Peer::new(
|
||||||
|
"transit",
|
||||||
|
dump.peers[peer_index].address,
|
||||||
|
dump.peers[peer_index].asn,
|
||||||
|
64601,
|
||||||
|
"provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
// What it costs to go over the routes and do the work of the typical
|
||||||
|
// function in Rust.
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut taken = 0usize;
|
||||||
|
for (_, (attributes, _)) in &routes {
|
||||||
|
let mut communities: Vec<(u16, u16)> = attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::Communities(communities) => Some(communities.0.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if communities.contains(&(65535, 666)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
communities.retain(|community| community.0 != 64601);
|
||||||
|
communities.push((64601, 3000));
|
||||||
|
let mut changed = attributes.clone();
|
||||||
|
changed.push(PathAttribute::Communities(
|
||||||
|
bgp_packet::message::CommunitiesPathAttribute(communities),
|
||||||
|
));
|
||||||
|
black_box(&changed);
|
||||||
|
taken += 1;
|
||||||
|
}
|
||||||
|
row(
|
||||||
|
"the typical one in Rust, for every route",
|
||||||
|
&Outcome {
|
||||||
|
elapsed: started.elapsed(),
|
||||||
|
asked: routes.len(),
|
||||||
|
taken,
|
||||||
|
changed: taken,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for function in ["nothing", "filter", "typical", "by_prefix"] {
|
||||||
|
let hook = policy.hook(function).expect("the function");
|
||||||
|
row(
|
||||||
|
&format!("{function}, asked for every route"),
|
||||||
|
&ask(&hook, &peer, routes.iter().copied()),
|
||||||
|
);
|
||||||
|
if function != "by_prefix" {
|
||||||
|
row(
|
||||||
|
&format!("{function}, asked once for each set of attributes"),
|
||||||
|
&ask(&hook, &peer, once_per_set.iter().copied()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ publish = false
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
bgp-fib.workspace = true
|
bgp-fib.workspace = true
|
||||||
bgp-packet.workspace = true
|
bgp-packet.workspace = true
|
||||||
|
bgp-policy.workspace = true
|
||||||
bgp-primitives.workspace = true
|
bgp-primitives.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
bgp-rpki.workspace = true
|
bgp-rpki.workspace = true
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ impl TestServer {
|
|||||||
http_endpoint: None,
|
http_endpoint: None,
|
||||||
rpki: None,
|
rpki: None,
|
||||||
kernel: None,
|
kernel: None,
|
||||||
|
policy: None,
|
||||||
};
|
};
|
||||||
customize(&mut config);
|
customize(&mut config);
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ async fn server_offering(
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
c.originate = originate;
|
c.originate = originate;
|
||||||
// Keep the dialer out of the way.
|
// Keep the dialer out of the way.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ fn peer_at(endpoint: SocketAddr) -> PeerConfig {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ async fn server_with(
|
|||||||
import,
|
import,
|
||||||
role,
|
role,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ async fn server_dialing(listener: &TcpListener) -> Result<TestServer> {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ async fn test_refused_routes_can_be_seen_and_counted() -> Result<()> {
|
|||||||
},
|
},
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ async fn server_dialing(listener: &TcpListener, remote_asn: u32) -> Result<TestS
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -194,6 +196,8 @@ async fn test_inbound_from_configured_address_uses_config() -> Result<()> {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -245,6 +249,8 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -265,6 +271,8 @@ async fn test_local_address_is_used_as_the_source() -> Result<()> {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ async fn server_with(import: ImportPolicy) -> Result<(TestServer, TcpListener)>
|
|||||||
import,
|
import,
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ async fn test_no_family_in_common_refuses_the_session() -> Result<()> {
|
|||||||
import: ImportPolicy::permissive(),
|
import: ImportPolicy::permissive(),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
// Keep the dialer out of the way.
|
// Keep the dialer out of the way.
|
||||||
c.connect_retry = Duration::from_secs(3600);
|
c.connect_retry = Duration::from_secs(3600);
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
//! The operator's own rules, as a script: what they do to routes on their
|
||||||
|
//! way in, and to what a peer is told.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bgp_integration_tests::default_policy;
|
||||||
|
use bgp_integration_tests::test_peer::TestPeer;
|
||||||
|
use bgp_integration_tests::test_server::TestServer;
|
||||||
|
use bgp_packet::message::{
|
||||||
|
AsPathAttribute, CommunitiesPathAttribute, LocalPrefPathAttribute, Message,
|
||||||
|
NextHopPathAttribute, OriginPathAttribute, PathAttribute, UpdateMessage,
|
||||||
|
};
|
||||||
|
use bgp_policy::Policy;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||||
|
use bgp_server::config::PeerConfig;
|
||||||
|
use bgp_server::family::FamilySet;
|
||||||
|
use bgp_server::policy::PeerRole;
|
||||||
|
use eyre::Result;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
const LOCAL_ASN: u32 = 1299;
|
||||||
|
const PEER_ASN: u32 = 3333;
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
const SCRIPT: &str = r#"
|
||||||
|
const BLACKHOLE = "65535:666";
|
||||||
|
const LEARNED_FROM = #{ customer: "1299:1000", peer: "1299:2000", provider: "1299:3000" };
|
||||||
|
|
||||||
|
fn from_peer(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
// Nobody else's word for where we learned a route.
|
||||||
|
route.remove_community("1299:*");
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = if route.has_community("3333:80") { 80 } else { 200 };
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn by_prefix(route, peer) {
|
||||||
|
if route.within("194.0.0.0/8") { return false; }
|
||||||
|
if route.prefix == "193.0.4.0/24" { route.add_community("1299:4"); }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broken(route, peer) {
|
||||||
|
if route.prefix_len == 22 { return route.no_such_thing; }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_peer(route, peer) {
|
||||||
|
if route.has_community("1299:9") { return false; } // not for anyone else
|
||||||
|
if route.within("193.0.0.0/21") { route.prepend(2); }
|
||||||
|
route.add_community("1299:" + peer.asn % 1000);
|
||||||
|
route.med = 50;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn prefix(s: &str) -> IpPrefix {
|
||||||
|
IpPrefix::try_from(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server with one configured peer, whose routes are given to the
|
||||||
|
/// script's function `import`, and which is told what `export` lets by.
|
||||||
|
async fn server_with(
|
||||||
|
import: Option<&str>,
|
||||||
|
export: Option<&str>,
|
||||||
|
originate: Vec<LocalRoute>,
|
||||||
|
) -> Result<(TestServer, TcpListener)> {
|
||||||
|
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||||
|
let endpoint = listener.local_addr()?;
|
||||||
|
let policy = Policy::compile(SCRIPT)?;
|
||||||
|
let import_hook = import.map(|name| policy.hook(name)).transpose()?;
|
||||||
|
let export_hook = export.map(|name| policy.hook(name)).transpose()?;
|
||||||
|
let server = TestServer::start_with(LOCAL_ASN, |c| {
|
||||||
|
c.accept_unconfigured_peers = false;
|
||||||
|
c.originate = originate;
|
||||||
|
c.policy = Some(policy);
|
||||||
|
c.peers = vec![PeerConfig {
|
||||||
|
name: "r1".into(),
|
||||||
|
endpoint: endpoint.into(),
|
||||||
|
remote_asn: PEER_ASN,
|
||||||
|
hold_time_secs: 90,
|
||||||
|
local_address: None,
|
||||||
|
families: FamilySet::supported(),
|
||||||
|
add_path: None,
|
||||||
|
import: default_policy(),
|
||||||
|
role: Some(PeerRole::Peer),
|
||||||
|
require_role: false,
|
||||||
|
import_hook,
|
||||||
|
export_hook,
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok((server, listener))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn established(server: &TestServer) -> Result<TestPeer> {
|
||||||
|
let mut peer = TestPeer::connect(server.addr).await?;
|
||||||
|
peer.handshake(PEER_ASN, Ipv4Addr::new(10, 0, 0, 2), 90)
|
||||||
|
.await?;
|
||||||
|
tokio::time::timeout(TIMEOUT, async {
|
||||||
|
while server.registry.list().is_empty() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn announce(
|
||||||
|
peer: &mut TestPeer,
|
||||||
|
prefixes: &[&str],
|
||||||
|
communities: &[(u16, u16)],
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut path_attributes = vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![PEER_ASN]),
|
||||||
|
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 1))),
|
||||||
|
];
|
||||||
|
if !communities.is_empty() {
|
||||||
|
path_attributes.push(PathAttribute::Communities(CommunitiesPathAttribute(
|
||||||
|
communities.to_vec(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
peer.send(&Message::Update(UpdateMessage {
|
||||||
|
withdrawn_routes: vec![],
|
||||||
|
path_attributes,
|
||||||
|
announced_routes: prefixes.iter().map(|p| prefix(p).into()).collect(),
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits until the server has taken `taken` routes of the session's and
|
||||||
|
/// refused `refused`.
|
||||||
|
async fn dealt_with(server: &TestServer, taken: u64, refused: u64) -> Result<()> {
|
||||||
|
let counters = server.registry.list()[0].counters.clone();
|
||||||
|
tokio::time::timeout(TIMEOUT, async {
|
||||||
|
while (counters.routes(), counters.rejected()) != (taken, refused) {
|
||||||
|
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
eyre::eyre!(
|
||||||
|
"the server took {} routes and refused {}; expected {taken} and {refused}",
|
||||||
|
counters.routes(),
|
||||||
|
counters.rejected()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A route's communities and its LOCAL_PREF.
|
||||||
|
type Marks = (Vec<(u16, u16)>, Option<u32>);
|
||||||
|
|
||||||
|
/// The communities and LOCAL_PREF each prefix is in the table with.
|
||||||
|
fn table(server: &TestServer) -> BTreeMap<String, Marks> {
|
||||||
|
server
|
||||||
|
.received_routes()
|
||||||
|
.iter()
|
||||||
|
.map(|route| {
|
||||||
|
let attributes = &route.attributes;
|
||||||
|
let communities = attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::Communities(communities) => Some(communities.0.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let local_pref = attributes.iter().find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::LocalPref(LocalPrefPathAttribute(value)) => Some(*value),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
(route.prefix.to_string(), (communities, local_pref))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refusals(server: &TestServer) -> Vec<(&'static str, u64)> {
|
||||||
|
server.refusals.peers()[0]
|
||||||
|
.1
|
||||||
|
.by_reason()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(reason, count)| (reason.as_str(), count))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Routes are marked with where they were learned and given a
|
||||||
|
/// preference, by what they carry and who sent them; one the function
|
||||||
|
/// refuses is not taken.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_routes_are_marked_preferred_and_refused_on_their_way_in() -> Result<()> {
|
||||||
|
let (server, _listener) = server_with(Some("from_peer"), None, vec![]).await?;
|
||||||
|
let mut peer = established(&server).await?;
|
||||||
|
|
||||||
|
// One UPDATE of two routes; one of a route with marks of its own, one
|
||||||
|
// of them forged; and one the function refuses.
|
||||||
|
announce(&mut peer, &["193.0.0.0/21", "193.0.8.0/22"], &[]).await?;
|
||||||
|
announce(&mut peer, &["194.0.1.0/24"], &[(3333, 80), (1299, 1000)]).await?;
|
||||||
|
announce(&mut peer, &["195.0.1.0/24"], &[(65535, 666)]).await?;
|
||||||
|
dealt_with(&server, 3, 1).await?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
table(&server),
|
||||||
|
BTreeMap::from([
|
||||||
|
("193.0.0.0/21".to_string(), (vec![(1299, 2000)], Some(200))),
|
||||||
|
("193.0.8.0/22".to_string(), (vec![(1299, 2000)], Some(200))),
|
||||||
|
(
|
||||||
|
"194.0.1.0/24".to_string(),
|
||||||
|
(vec![(3333, 80), (1299, 2000)], Some(80))
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(refusals(&server), [("refused by policy function", 1)]);
|
||||||
|
|
||||||
|
// A route the function took is replaced by one it refuses: gone.
|
||||||
|
announce(&mut peer, &["193.0.8.0/22"], &[(65535, 666)]).await?;
|
||||||
|
dealt_with(&server, 2, 2).await?;
|
||||||
|
assert!(!table(&server).contains_key("193.0.8.0/22"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function that asks for the prefix is asked about each route of an
|
||||||
|
/// UPDATE, and may answer differently for each.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_a_function_may_treat_the_routes_of_one_update_differently() -> Result<()> {
|
||||||
|
let (server, _listener) = server_with(Some("by_prefix"), None, vec![]).await?;
|
||||||
|
let mut peer = established(&server).await?;
|
||||||
|
announce(
|
||||||
|
&mut peer,
|
||||||
|
&[
|
||||||
|
"193.0.0.0/21",
|
||||||
|
"194.0.1.0/24",
|
||||||
|
"193.0.4.0/24",
|
||||||
|
"194.0.2.0/24",
|
||||||
|
"195.0.0.0/24",
|
||||||
|
],
|
||||||
|
&[(3333, 1)],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
dealt_with(&server, 3, 2).await?;
|
||||||
|
assert_eq!(
|
||||||
|
table(&server),
|
||||||
|
BTreeMap::from([
|
||||||
|
("193.0.0.0/21".to_string(), (vec![(3333, 1)], None)),
|
||||||
|
(
|
||||||
|
"193.0.4.0/24".to_string(),
|
||||||
|
(vec![(3333, 1), (1299, 4)], None)
|
||||||
|
),
|
||||||
|
("195.0.0.0/24".to_string(), (vec![(3333, 1)], None)),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
server.rib.attrs().len(),
|
||||||
|
2,
|
||||||
|
"two routes share their attributes"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function that fails on a route has refused it, and no other.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_a_function_that_fails_refuses_the_route() -> Result<()> {
|
||||||
|
let (server, _listener) = server_with(Some("broken"), None, vec![]).await?;
|
||||||
|
let mut peer = established(&server).await?;
|
||||||
|
announce(
|
||||||
|
&mut peer,
|
||||||
|
&["193.0.0.0/21", "193.0.8.0/22", "195.0.0.0/24"],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
dealt_with(&server, 2, 1).await?;
|
||||||
|
assert_eq!(refusals(&server), [("policy function failed", 1)]);
|
||||||
|
assert!(!table(&server).contains_key("193.0.8.0/22"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a peer is told is what the function for it lets by, as the
|
||||||
|
/// function left it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_what_a_peer_is_told_goes_through_its_function() -> Result<()> {
|
||||||
|
let route = |prefix_text: &str, communities: Vec<(u16, u16)>| {
|
||||||
|
LocalRoute::new(
|
||||||
|
prefix(prefix_text),
|
||||||
|
LocalRouteAttrs {
|
||||||
|
communities,
|
||||||
|
..LocalRouteAttrs::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let originate = vec![
|
||||||
|
route("193.0.0.0/21", vec![(1299, 1)]),
|
||||||
|
route("194.0.0.0/22", vec![]),
|
||||||
|
route("195.0.0.0/22", vec![(1299, 9)]),
|
||||||
|
];
|
||||||
|
let (server, _listener) = server_with(None, Some("to_peer"), originate).await?;
|
||||||
|
let mut peer = established(&server).await?;
|
||||||
|
|
||||||
|
let mut told = BTreeMap::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let update = tokio::time::timeout(TIMEOUT, peer.recv_update()).await??;
|
||||||
|
let path = update
|
||||||
|
.path_attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::AsPath(path) => Some(path.segments[0].path.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let communities = update
|
||||||
|
.path_attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::Communities(communities) => Some(communities.0.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let med = update
|
||||||
|
.path_attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::MultiExitDisc(med) => Some(med.0),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
// In the order of their type codes, as any UPDATE of ours.
|
||||||
|
let codes: Vec<u8> = update
|
||||||
|
.path_attributes
|
||||||
|
.iter()
|
||||||
|
.map(PathAttribute::type_code)
|
||||||
|
.collect();
|
||||||
|
assert!(codes.is_sorted(), "{codes:?}");
|
||||||
|
// The peer is one: the route is still marked as only for customers.
|
||||||
|
assert!(codes.contains(&35), "{codes:?}");
|
||||||
|
told.insert(
|
||||||
|
update.announced_routes[0].prefix.to_string(),
|
||||||
|
(path, communities, med),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
told,
|
||||||
|
BTreeMap::from([
|
||||||
|
(
|
||||||
|
"193.0.0.0/21".to_string(),
|
||||||
|
(vec![LOCAL_ASN; 3], vec![(1299, 1), (1299, 333)], Some(50))
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"194.0.0.0/22".to_string(),
|
||||||
|
(vec![LOCAL_ASN], vec![(1299, 333)], Some(50))
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
// And nothing of the third.
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(Duration::from_millis(300), peer.recv_update())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -58,6 +58,8 @@ async fn server_with(role: Option<PeerRole>, require_role: bool) -> Result<Setup
|
|||||||
import: default_policy(),
|
import: default_policy(),
|
||||||
role,
|
role,
|
||||||
require_role,
|
require_role,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ async fn server_with(
|
|||||||
import,
|
import,
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}];
|
}];
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ impl PathAttribute {
|
|||||||
|
|
||||||
/// Parses one attribute, header and value, from the front of `buf`.
|
/// Parses one attribute, header and value, from the front of `buf`.
|
||||||
/// The flags are not checked against the attribute's definition;
|
/// The flags are not checked against the attribute's definition;
|
||||||
/// [`UpdateMessage::decode`] does that for attributes off a session.
|
/// [`crate::message::UpdateMessage::decode`] does that for attributes off a session.
|
||||||
pub fn from_wire<'a>(
|
pub fn from_wire<'a>(
|
||||||
ctx: &ParserContext,
|
ctx: &ParserContext,
|
||||||
buf: &'a [u8],
|
buf: &'a [u8],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "bgp-policy"
|
||||||
|
authors.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
homepage.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bgp-packet.workspace = true
|
||||||
|
bgp-primitives.workspace = true
|
||||||
|
foldhash.workspace = true
|
||||||
|
# The language policies are written in. Without what a policy has no use
|
||||||
|
# for: closures over outer variables, loading other files, clocks, floats.
|
||||||
|
# `sync` makes the engine something every session's thread can use.
|
||||||
|
rhai = { version = "1.26", default-features = false, features = ["std", "sync", "no_module", "no_time", "no_float", "no_custom_syntax"] }
|
||||||
|
thiserror.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
//! Policies written by the operator, in a script.
|
||||||
|
//!
|
||||||
|
//! The checks every route gets (`bgp_server::policy`) say what no network
|
||||||
|
//! should take from another. What a network wants beyond that is its own
|
||||||
|
//! business: which routes it prefers, what it marks them with, what it
|
||||||
|
//! tells which neighbor. That is said in a script, in [Rhai], as functions
|
||||||
|
//! that are given a route and the peer it came from or is going to:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! const BLACKHOLE = "65535:666";
|
||||||
|
//!
|
||||||
|
//! fn from_transit(route, peer) {
|
||||||
|
//! if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
//! route.add_community("64601:2000");
|
||||||
|
//! route.local_pref = 80;
|
||||||
|
//! true
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! A function returns whether the route is taken (or announced), and may
|
||||||
|
//! change the route before it does: its communities, LOCAL_PREF, MED, and
|
||||||
|
//! on the way out how often our AS is on its path. See [`route`] for all
|
||||||
|
//! of what a script is given.
|
||||||
|
//!
|
||||||
|
//! # What a script costs
|
||||||
|
//!
|
||||||
|
//! Rhai walks the script's syntax tree; a call that looks at a few
|
||||||
|
//! communities and changes one or two takes about a microsecond, two
|
||||||
|
//! hundred times what the same would in Rust. So a script is not run for
|
||||||
|
//! every route: routes that arrive in one UPDATE share their attributes,
|
||||||
|
//! and a function that does not ask for the prefix gives one answer for
|
||||||
|
//! all of them. [`Verdict::saw_prefix`] says whether it asked.
|
||||||
|
//!
|
||||||
|
//! # What a script cannot do
|
||||||
|
//!
|
||||||
|
//! Anything but compute: there are no files, no network, no clock. One
|
||||||
|
//! that runs for too long is stopped ([`MAX_OPERATIONS`]). A function that
|
||||||
|
//! fails (it is stopped, or asks for something that is not there) has
|
||||||
|
//! refused the route: failing must not be a way through.
|
||||||
|
//!
|
||||||
|
//! [Rhai]: https://rhai.rs
|
||||||
|
|
||||||
|
pub mod route;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bgp_packet::message::{NlriNextHop, PathAttribute};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use rhai::{AST, CallFnOptions, Dynamic, Engine, Scope};
|
||||||
|
|
||||||
|
pub use route::Peer;
|
||||||
|
use route::Route;
|
||||||
|
|
||||||
|
/// How many steps one call of a function may take. A function that looks
|
||||||
|
/// at every community of a route with a hundred of them takes a few
|
||||||
|
/// thousand.
|
||||||
|
pub const MAX_OPERATIONS: u64 = 200_000;
|
||||||
|
|
||||||
|
/// What is wrong with a script, or went wrong in one.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum PolicyError {
|
||||||
|
#[error("the script does not parse: {0}")]
|
||||||
|
Parse(String),
|
||||||
|
#[error("the script failed when it was loaded: {0}")]
|
||||||
|
Load(String),
|
||||||
|
#[error("the script has no function {name:?}; it has: {known}")]
|
||||||
|
NoSuchFunction { name: String, known: String },
|
||||||
|
#[error(
|
||||||
|
"the function {name:?} takes {takes} arguments; a policy takes two, the route and the peer"
|
||||||
|
)]
|
||||||
|
Arguments { name: String, takes: usize },
|
||||||
|
#[error("{function}: {message}")]
|
||||||
|
Run { function: String, message: String },
|
||||||
|
#[error("{function} returned {returned}; a policy returns true or false")]
|
||||||
|
Returned { function: String, returned: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether routes are coming in or going out.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Direction {
|
||||||
|
/// A route a peer announced, on its way into the table.
|
||||||
|
Import,
|
||||||
|
/// A route on its way to a peer.
|
||||||
|
Export,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A script, read and ready to be called.
|
||||||
|
pub struct Policy {
|
||||||
|
engine: Engine,
|
||||||
|
ast: AST,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Policy {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Policy")
|
||||||
|
.field("functions", &self.functions())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Policy {
|
||||||
|
/// Reads `source`. What it has outside of functions is run once, now:
|
||||||
|
/// that is where its constants are defined.
|
||||||
|
pub fn compile(source: &str) -> Result<Arc<Self>, PolicyError> {
|
||||||
|
let mut engine = Engine::new();
|
||||||
|
engine.set_max_operations(MAX_OPERATIONS);
|
||||||
|
engine.set_max_call_levels(32);
|
||||||
|
engine.set_max_expr_depths(64, 32);
|
||||||
|
engine.set_max_string_size(16 * 1024);
|
||||||
|
engine.set_max_array_size(16 * 1024);
|
||||||
|
engine.set_max_map_size(1024);
|
||||||
|
// What a script prints is for whoever is writing it.
|
||||||
|
engine.on_print(|text| tracing::info!(target: "bgp_policy::script", "{text}"));
|
||||||
|
engine.on_debug(|text, _, position| {
|
||||||
|
tracing::debug!(target: "bgp_policy::script", %position, "{text}");
|
||||||
|
});
|
||||||
|
route::register(&mut engine);
|
||||||
|
|
||||||
|
let ast = engine
|
||||||
|
.compile(source)
|
||||||
|
.map_err(|e| PolicyError::Parse(e.to_string()))?;
|
||||||
|
let mut globals = Scope::new();
|
||||||
|
engine
|
||||||
|
.run_ast_with_scope(&mut globals, &ast)
|
||||||
|
.map_err(|e| PolicyError::Load(e.to_string()))?;
|
||||||
|
// A Rhai function sees nothing outside itself. A policy's
|
||||||
|
// constants are for all its functions, so they are found for
|
||||||
|
// them: this is asked for every variable a function names, before
|
||||||
|
// the function's own are looked at.
|
||||||
|
let constants: HashMap<String, Dynamic, foldhash::fast::RandomState> = globals
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _, value)| (name.to_string(), value))
|
||||||
|
.collect();
|
||||||
|
if !constants.is_empty() {
|
||||||
|
#[allow(deprecated, reason = "the API is marked volatile, not obsolete")]
|
||||||
|
engine.on_var(move |name, _, _| Ok(constants.get(name).cloned()));
|
||||||
|
}
|
||||||
|
Ok(Arc::new(Self { engine, ast }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The names of the script's functions, in order.
|
||||||
|
pub fn functions(&self) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = self
|
||||||
|
.ast
|
||||||
|
.iter_functions()
|
||||||
|
.map(|function| function.name.to_string())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The function called `name`, to be run on routes.
|
||||||
|
pub fn hook(self: &Arc<Self>, name: &str) -> Result<Hook, PolicyError> {
|
||||||
|
let found: Vec<usize> = self
|
||||||
|
.ast
|
||||||
|
.iter_functions()
|
||||||
|
.filter(|function| function.name == name)
|
||||||
|
.map(|function| function.params.len())
|
||||||
|
.collect();
|
||||||
|
match found.as_slice() {
|
||||||
|
[] => Err(PolicyError::NoSuchFunction {
|
||||||
|
name: name.to_string(),
|
||||||
|
known: self.functions().join(", "),
|
||||||
|
}),
|
||||||
|
takes if takes.contains(&2) => Ok(Hook {
|
||||||
|
policy: Arc::clone(self),
|
||||||
|
name: name.to_string(),
|
||||||
|
}),
|
||||||
|
[takes, ..] => Err(PolicyError::Arguments {
|
||||||
|
name: name.to_string(),
|
||||||
|
takes: *takes,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A route as a policy is asked about it.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Subject<'a> {
|
||||||
|
pub prefix: &'a IpPrefix,
|
||||||
|
/// The route's attributes, but for its next hop if that came with the
|
||||||
|
/// prefixes (MP_REACH_NLRI).
|
||||||
|
pub attributes: &'a [PathAttribute],
|
||||||
|
pub next_hop: Option<NlriNextHop>,
|
||||||
|
pub direction: Direction,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a policy made of a route.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Verdict {
|
||||||
|
/// Whether the route is taken, or announced.
|
||||||
|
pub accept: bool,
|
||||||
|
/// The route's attributes as the policy left them, if it changed any.
|
||||||
|
pub attributes: Option<Vec<PathAttribute>>,
|
||||||
|
/// Whether the policy asked what the route's prefix is. If it did not,
|
||||||
|
/// its answer is the same for any other prefix with these attributes
|
||||||
|
/// from this peer, and need not be asked for again.
|
||||||
|
pub saw_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One function of a policy. Cheap to clone.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Hook {
|
||||||
|
policy: Arc<Policy>,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Hook {
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the function about a route from, or for, `peer`.
|
||||||
|
pub fn run(&self, peer: &Peer, subject: &Subject<'_>) -> Result<Verdict, PolicyError> {
|
||||||
|
let route = Route::new(subject, peer);
|
||||||
|
let returned = self
|
||||||
|
.policy
|
||||||
|
.engine
|
||||||
|
.call_fn_with_options::<Dynamic>(
|
||||||
|
// The script's top level was run when it was loaded.
|
||||||
|
CallFnOptions::new().eval_ast(false),
|
||||||
|
&mut Scope::new(),
|
||||||
|
&self.policy.ast,
|
||||||
|
&self.name,
|
||||||
|
(route.clone(), peer.clone()),
|
||||||
|
)
|
||||||
|
.map_err(|e| PolicyError::Run {
|
||||||
|
function: self.name.clone(),
|
||||||
|
message: e.to_string(),
|
||||||
|
})?;
|
||||||
|
// A function that ends without saying has no objection.
|
||||||
|
let accept = if returned.is_unit() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
returned.as_bool().map_err(|_| PolicyError::Returned {
|
||||||
|
function: self.name.clone(),
|
||||||
|
returned: returned.to_string(),
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
let (attributes, saw_prefix) = route.finish(subject.attributes);
|
||||||
|
Ok(Verdict {
|
||||||
|
accept,
|
||||||
|
attributes: attributes.filter(|_| accept),
|
||||||
|
saw_prefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
//! What a policy function is given: the route and the peer.
|
||||||
|
//!
|
||||||
|
//! # The route
|
||||||
|
//!
|
||||||
|
//! | In a script | |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `route.prefix` | `"203.0.113.0/24"` |
|
||||||
|
//! | `route.prefix_len` | `24` |
|
||||||
|
//! | `route.within("203.0.112.0/22")` | whether the prefix is that one or inside it |
|
||||||
|
//! | `route.family` | `"ipv4"` or `"ipv6"` |
|
||||||
|
//! | `route.as_path` | `[64602, 3356, 15169]`, the neighbor's AS first |
|
||||||
|
//! | `route.as_path_len` | as route selection counts it: a set is one |
|
||||||
|
//! | `route.origin_as`, `route.neighbor_as` | the last and the first AS of the path; 0 if there is none |
|
||||||
|
//! | `route.path_has(3356)` | whether the AS is anywhere on the path |
|
||||||
|
//! | `route.origin` | `"igp"`, `"egp"` or `"incomplete"` |
|
||||||
|
//! | `route.next_hop` | `"192.0.2.1"` |
|
||||||
|
//! | `route.med`, `route.local_pref` | the number, or `()` if the route has none; both can be set, and unset with `()` |
|
||||||
|
//! | `route.communities` | `["3356:3", "3356:2012"]` |
|
||||||
|
//! | `route.has_community("3356:3")` | also `(3356, 3)`, and `"3356:*"` for any of that AS |
|
||||||
|
//! | `route.add_community("64601:100")` | also `(64601, 100)` |
|
||||||
|
//! | `route.remove_community("3356:*")` | all that match; `"*:*"` for all there are |
|
||||||
|
//! | `route.large_communities`, `route.has_large_community("64601:1:*")`, `route.add_large_community(..)`, `route.remove_large_community(..)` | the same for large communities (RFC 8092) |
|
||||||
|
//! | `route.prepend(2)` | on the way out: our AS that many more times on the path |
|
||||||
|
//!
|
||||||
|
//! # The peer
|
||||||
|
//!
|
||||||
|
//! `peer.name` (as configured), `peer.asn`, `peer.address`, `peer.role`
|
||||||
|
//! (`"customer"`, `"provider"`, `"peer"`, `"route_server"`,
|
||||||
|
//! `"route_server_client"`, or `""` if none is configured), `peer.ibgp`,
|
||||||
|
//! and `peer.local_asn`, which is our own.
|
||||||
|
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||||
|
|
||||||
|
use bgp_packet::community::{format_large, format_standard};
|
||||||
|
use bgp_packet::message::{
|
||||||
|
AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute,
|
||||||
|
LargeCommunity, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NlriNextHop,
|
||||||
|
OriginPathAttribute, PathAttribute,
|
||||||
|
};
|
||||||
|
use bgp_primitives::constants::IpVersion;
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use rhai::{Array, Dynamic, Engine, EvalAltResult, INT, ImmutableString};
|
||||||
|
|
||||||
|
use crate::{Direction, Subject};
|
||||||
|
|
||||||
|
type Fallible<T> = Result<T, Box<EvalAltResult>>;
|
||||||
|
|
||||||
|
/// A peer, as a script sees it. Made once for a session.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Peer {
|
||||||
|
name: ImmutableString,
|
||||||
|
address: ImmutableString,
|
||||||
|
role: ImmutableString,
|
||||||
|
asn: u32,
|
||||||
|
local_asn: u32,
|
||||||
|
ibgp: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer {
|
||||||
|
/// `role` is the peer's role by the name scripts know it under, or
|
||||||
|
/// empty.
|
||||||
|
pub fn new(name: &str, address: IpAddr, asn: u32, local_asn: u32, role: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
address: address.to_string().into(),
|
||||||
|
role: role.into(),
|
||||||
|
asn,
|
||||||
|
local_asn,
|
||||||
|
ibgp: asn == local_asn,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The route a function is looking at, and what it has done to it.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct State {
|
||||||
|
prefix: IpPrefix,
|
||||||
|
direction: Direction,
|
||||||
|
local_asn: u32,
|
||||||
|
ibgp: bool,
|
||||||
|
/// The AS numbers of the path in order, sets and all.
|
||||||
|
as_path: Vec<u32>,
|
||||||
|
as_path_len: usize,
|
||||||
|
origin_as: u32,
|
||||||
|
neighbor_as: u32,
|
||||||
|
origin: OriginPathAttribute,
|
||||||
|
next_hop: Option<NlriNextHop>,
|
||||||
|
communities: Vec<(u16, u16)>,
|
||||||
|
large_communities: Vec<LargeCommunity>,
|
||||||
|
local_pref: Option<u32>,
|
||||||
|
med: Option<u32>,
|
||||||
|
/// How many more times our AS is to go on the path.
|
||||||
|
prepend: usize,
|
||||||
|
saw_prefix: bool,
|
||||||
|
/// Which of the above the function changed.
|
||||||
|
changed: Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
struct Changed {
|
||||||
|
communities: bool,
|
||||||
|
large_communities: bool,
|
||||||
|
local_pref: bool,
|
||||||
|
med: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The handle a script holds. The function is given a copy and changes
|
||||||
|
/// what it points to; the caller reads that afterwards.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct Route(Arc<Mutex<State>>);
|
||||||
|
|
||||||
|
impl Route {
|
||||||
|
pub(crate) fn new(subject: &Subject<'_>, peer: &Peer) -> Self {
|
||||||
|
let mut state = State {
|
||||||
|
prefix: *subject.prefix,
|
||||||
|
direction: subject.direction,
|
||||||
|
local_asn: peer.local_asn,
|
||||||
|
ibgp: peer.ibgp,
|
||||||
|
as_path: Vec::new(),
|
||||||
|
as_path_len: 0,
|
||||||
|
origin_as: 0,
|
||||||
|
neighbor_as: 0,
|
||||||
|
origin: OriginPathAttribute::INCOMPLETE,
|
||||||
|
next_hop: subject.next_hop,
|
||||||
|
communities: Vec::new(),
|
||||||
|
large_communities: Vec::new(),
|
||||||
|
local_pref: None,
|
||||||
|
med: None,
|
||||||
|
prepend: 0,
|
||||||
|
saw_prefix: false,
|
||||||
|
changed: Changed::default(),
|
||||||
|
};
|
||||||
|
for attribute in subject.attributes {
|
||||||
|
match attribute {
|
||||||
|
PathAttribute::Origin(origin) => state.origin = *origin,
|
||||||
|
PathAttribute::AsPath(path) => {
|
||||||
|
state.as_path_len = path.hops();
|
||||||
|
let first = path.segments.first().filter(|segment| segment.ordered);
|
||||||
|
let last = path.segments.last().filter(|segment| segment.ordered);
|
||||||
|
state.neighbor_as = first.and_then(|s| s.path.first()).copied().unwrap_or(0);
|
||||||
|
state.origin_as = last.and_then(|s| s.path.last()).copied().unwrap_or(0);
|
||||||
|
state.as_path = path
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.flat_map(|segment| segment.path.iter().copied())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
PathAttribute::NextHop(next_hop) => {
|
||||||
|
state.next_hop = Some(NlriNextHop::Ipv4(next_hop.0));
|
||||||
|
}
|
||||||
|
PathAttribute::MultiExitDisc(med) => state.med = Some(med.0),
|
||||||
|
PathAttribute::LocalPref(local_pref) => state.local_pref = Some(local_pref.0),
|
||||||
|
PathAttribute::Communities(communities) => {
|
||||||
|
state.communities.clone_from(&communities.0);
|
||||||
|
}
|
||||||
|
PathAttribute::LargeCommunities(large) => {
|
||||||
|
state.large_communities.clone_from(&large.communities);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self(Arc::new(Mutex::new(state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> MutexGuard<'_, State> {
|
||||||
|
self.0.lock().unwrap_or_else(PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the function left: `attributes` as changed, if it changed
|
||||||
|
/// any, and whether it asked for the prefix.
|
||||||
|
pub(crate) fn finish(
|
||||||
|
&self,
|
||||||
|
attributes: &[PathAttribute],
|
||||||
|
) -> (Option<Vec<PathAttribute>>, bool) {
|
||||||
|
let state = self.state();
|
||||||
|
let changed = state.changed;
|
||||||
|
let prepend = state.prepend > 0 && !state.ibgp;
|
||||||
|
let any = changed.communities
|
||||||
|
|| changed.large_communities
|
||||||
|
|| changed.local_pref
|
||||||
|
|| changed.med
|
||||||
|
|| prepend;
|
||||||
|
if !any {
|
||||||
|
return (None, state.saw_prefix);
|
||||||
|
}
|
||||||
|
let mut attributes = attributes.to_vec();
|
||||||
|
if changed.communities {
|
||||||
|
let communities = (!state.communities.is_empty()).then(|| {
|
||||||
|
PathAttribute::Communities(CommunitiesPathAttribute(state.communities.clone()))
|
||||||
|
});
|
||||||
|
put(
|
||||||
|
&mut attributes,
|
||||||
|
bgp_packet::message::attr_type::COMMUNITIES,
|
||||||
|
communities,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if changed.large_communities {
|
||||||
|
let large = (!state.large_communities.is_empty()).then(|| {
|
||||||
|
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||||
|
communities: state.large_communities.clone(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
put(
|
||||||
|
&mut attributes,
|
||||||
|
bgp_packet::message::attr_type::LARGE_COMMUNITIES,
|
||||||
|
large,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if changed.local_pref {
|
||||||
|
let local_pref = state
|
||||||
|
.local_pref
|
||||||
|
.map(|value| PathAttribute::LocalPref(LocalPrefPathAttribute(value)));
|
||||||
|
put(
|
||||||
|
&mut attributes,
|
||||||
|
bgp_packet::message::attr_type::LOCAL_PREF,
|
||||||
|
local_pref,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if changed.med {
|
||||||
|
let med = state
|
||||||
|
.med
|
||||||
|
.map(|value| PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(value)));
|
||||||
|
put(
|
||||||
|
&mut attributes,
|
||||||
|
bgp_packet::message::attr_type::MULTI_EXIT_DISC,
|
||||||
|
med,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if prepend {
|
||||||
|
let ours = std::iter::repeat_n(state.local_asn, state.prepend);
|
||||||
|
for attribute in &mut attributes {
|
||||||
|
if let PathAttribute::AsPath(path) = attribute {
|
||||||
|
prepend_to(path, ours);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(attributes), state.saw_prefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts `ours` at the front of `path`.
|
||||||
|
fn prepend_to(path: &mut AsPathAttribute, ours: impl Iterator<Item = u32>) {
|
||||||
|
match path.segments.first_mut() {
|
||||||
|
Some(first) if first.ordered => {
|
||||||
|
first.path.splice(0..0, ours);
|
||||||
|
}
|
||||||
|
_ => path.segments.insert(
|
||||||
|
0,
|
||||||
|
AsPathSegment {
|
||||||
|
ordered: true,
|
||||||
|
path: ours.collect(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes the attribute of type `type_code` in `attributes` be `with`:
|
||||||
|
/// replaced, added in the order of the type codes, or taken out.
|
||||||
|
fn put(attributes: &mut Vec<PathAttribute>, type_code: u8, with: Option<PathAttribute>) {
|
||||||
|
let at = attributes
|
||||||
|
.iter()
|
||||||
|
.position(|attribute| attribute.type_code() == type_code);
|
||||||
|
match (at, with) {
|
||||||
|
(Some(at), Some(with)) => attributes[at] = with,
|
||||||
|
(Some(at), None) => {
|
||||||
|
attributes.remove(at);
|
||||||
|
}
|
||||||
|
(None, Some(with)) => {
|
||||||
|
let after = attributes
|
||||||
|
.iter()
|
||||||
|
.position(|attribute| attribute.type_code() > type_code)
|
||||||
|
.unwrap_or(attributes.len());
|
||||||
|
attributes.insert(after, with);
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrong(what: impl Into<String>) -> Box<EvalAltResult> {
|
||||||
|
what.into().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A number a script gave, as one that fits where it is going.
|
||||||
|
fn number<T: TryFrom<INT>>(value: INT, what: &str) -> Fallible<T> {
|
||||||
|
T::try_from(value).map_err(|_| wrong(format!("{value} is not {what}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One part of a community as a script writes it: a number, or `*` for
|
||||||
|
/// any.
|
||||||
|
fn part<T: std::str::FromStr>(text: &str, whole: &str) -> Fallible<Option<T>> {
|
||||||
|
if text == "*" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
text.parse()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| wrong(format!("{whole:?} is not a community")))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pattern = (Option<u16>, Option<u16>);
|
||||||
|
type LargePattern = (Option<u32>, Option<u32>, Option<u32>);
|
||||||
|
|
||||||
|
fn pattern(text: &str) -> Fallible<Pattern> {
|
||||||
|
let (high, low) = text.split_once(':').ok_or_else(|| {
|
||||||
|
wrong(format!(
|
||||||
|
"{text:?} is not a community: it is written 64601:100"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok((part(high, text)?, part(low, text)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn large_pattern(text: &str) -> Fallible<LargePattern> {
|
||||||
|
let mut parts = text.splitn(3, ':');
|
||||||
|
let mut next = || {
|
||||||
|
parts.next().ok_or_else(|| {
|
||||||
|
wrong(format!(
|
||||||
|
"{text:?} is not a large community: it is written 64601:1:2"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let (admin, first, second) = (next()?, next()?, next()?);
|
||||||
|
Ok((part(admin, text)?, part(first, text)?, part(second, text)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pattern with nothing left open: a community to add.
|
||||||
|
fn exact((high, low): Pattern, text: &str) -> Fallible<(u16, u16)> {
|
||||||
|
high.zip(low).ok_or_else(|| {
|
||||||
|
wrong(format!(
|
||||||
|
"{text:?} cannot be added: it stands for more than one"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exact_large((admin, first, second): LargePattern, text: &str) -> Fallible<LargeCommunity> {
|
||||||
|
match (admin, first, second) {
|
||||||
|
(Some(global_admin), Some(data_1), Some(data_2)) => Ok(LargeCommunity {
|
||||||
|
global_admin,
|
||||||
|
data_1,
|
||||||
|
data_2,
|
||||||
|
}),
|
||||||
|
_ => Err(wrong(format!(
|
||||||
|
"{text:?} cannot be added: it stands for more than one"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fits<T: PartialEq>(wanted: Option<T>, has: T) -> bool {
|
||||||
|
wanted.is_none_or(|wanted| wanted == has)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches((high, low): Pattern, community: (u16, u16)) -> bool {
|
||||||
|
fits(high, community.0) && fits(low, community.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_large((admin, first, second): LargePattern, community: &LargeCommunity) -> bool {
|
||||||
|
fits(admin, community.global_admin)
|
||||||
|
&& fits(first, community.data_1)
|
||||||
|
&& fits(second, community.data_2)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
fn add_community(&mut self, community: (u16, u16)) {
|
||||||
|
if !self.communities.contains(&community) {
|
||||||
|
self.communities.push(community);
|
||||||
|
self.changed.communities = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_communities(&mut self, pattern: Pattern) {
|
||||||
|
let before = self.communities.len();
|
||||||
|
self.communities
|
||||||
|
.retain(|community| !matches(pattern, *community));
|
||||||
|
self.changed.communities |= self.communities.len() != before;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_local_pref(&mut self, value: Option<u32>) {
|
||||||
|
self.changed.local_pref |= self.local_pref != value;
|
||||||
|
self.local_pref = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_med(&mut self, value: Option<u32>) {
|
||||||
|
self.changed.med |= self.med != value;
|
||||||
|
self.med = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional(value: Option<u32>) -> Dynamic {
|
||||||
|
value.map_or(Dynamic::UNIT, |value| Dynamic::from(INT::from(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells `engine` what a route and a peer are, and what can be asked of
|
||||||
|
/// them.
|
||||||
|
pub(crate) fn register(engine: &mut Engine) {
|
||||||
|
engine.register_type_with_name::<Route>("Route");
|
||||||
|
engine.register_type_with_name::<Peer>("Peer");
|
||||||
|
|
||||||
|
// The peer.
|
||||||
|
engine.register_get("name", |peer: &mut Peer| peer.name.clone());
|
||||||
|
engine.register_get("address", |peer: &mut Peer| peer.address.clone());
|
||||||
|
engine.register_get("role", |peer: &mut Peer| peer.role.clone());
|
||||||
|
engine.register_get("asn", |peer: &mut Peer| INT::from(peer.asn));
|
||||||
|
engine.register_get("local_asn", |peer: &mut Peer| INT::from(peer.local_asn));
|
||||||
|
engine.register_get("ibgp", |peer: &mut Peer| peer.ibgp);
|
||||||
|
|
||||||
|
// The prefix. Asking for it is noted: the answer to a function that
|
||||||
|
// did not is good for every prefix.
|
||||||
|
engine.register_get("prefix", |route: &mut Route| {
|
||||||
|
let mut state = route.state();
|
||||||
|
state.saw_prefix = true;
|
||||||
|
state.prefix.to_string()
|
||||||
|
});
|
||||||
|
engine.register_get("prefix_len", |route: &mut Route| {
|
||||||
|
let mut state = route.state();
|
||||||
|
state.saw_prefix = true;
|
||||||
|
INT::from(state.prefix.length())
|
||||||
|
});
|
||||||
|
engine.register_fn(
|
||||||
|
"within",
|
||||||
|
|route: &mut Route, around: &str| -> Fallible<bool> {
|
||||||
|
let around = IpPrefix::try_from(around)
|
||||||
|
.map_err(|e| wrong(format!("{around:?} is not a prefix: {e}")))?;
|
||||||
|
let mut state = route.state();
|
||||||
|
state.saw_prefix = true;
|
||||||
|
Ok(around.contains(&state.prefix))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Every route of an UPDATE's one batch is of the same family.
|
||||||
|
engine.register_get("family", |route: &mut Route| {
|
||||||
|
match route.state().prefix.address_family() {
|
||||||
|
IpVersion::Ipv4 => "ipv4",
|
||||||
|
IpVersion::Ipv6 => "ipv6",
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The path.
|
||||||
|
engine.register_get("as_path", |route: &mut Route| -> Array {
|
||||||
|
route
|
||||||
|
.state()
|
||||||
|
.as_path
|
||||||
|
.iter()
|
||||||
|
.map(|asn| Dynamic::from(INT::from(*asn)))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
engine.register_get("as_path_len", |route: &mut Route| {
|
||||||
|
route.state().as_path_len as INT
|
||||||
|
});
|
||||||
|
engine.register_get("origin_as", |route: &mut Route| {
|
||||||
|
INT::from(route.state().origin_as)
|
||||||
|
});
|
||||||
|
engine.register_get("neighbor_as", |route: &mut Route| {
|
||||||
|
INT::from(route.state().neighbor_as)
|
||||||
|
});
|
||||||
|
engine.register_fn("path_has", |route: &mut Route, asn: INT| {
|
||||||
|
u32::try_from(asn).is_ok_and(|asn| route.state().as_path.contains(&asn))
|
||||||
|
});
|
||||||
|
engine.register_fn("prepend", |route: &mut Route, times: INT| -> Fallible<()> {
|
||||||
|
let mut state = route.state();
|
||||||
|
if state.direction == Direction::Import {
|
||||||
|
return Err(wrong(
|
||||||
|
"prepend is for routes on their way out; to make a route less preferred on \
|
||||||
|
its way in, set its local_pref",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// More than this is a mistake, and makes the route no worse.
|
||||||
|
state.prepend = (0..=16)
|
||||||
|
.contains(×)
|
||||||
|
.then_some(times as usize)
|
||||||
|
.ok_or_else(|| wrong(format!("prepend({times}): from 0 to 16 times")))?;
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other attributes that are looked at.
|
||||||
|
engine.register_get("origin", |route: &mut Route| match route.state().origin {
|
||||||
|
OriginPathAttribute::IGP => "igp",
|
||||||
|
OriginPathAttribute::EGP => "egp",
|
||||||
|
OriginPathAttribute::INCOMPLETE => "incomplete",
|
||||||
|
});
|
||||||
|
engine.register_get("next_hop", |route: &mut Route| {
|
||||||
|
route
|
||||||
|
.state()
|
||||||
|
.next_hop
|
||||||
|
.map_or_else(String::new, |next_hop| match next_hop {
|
||||||
|
NlriNextHop::Ipv4(address) => address.to_string(),
|
||||||
|
NlriNextHop::Ipv6(address) => address.to_string(),
|
||||||
|
NlriNextHop::Ipv6WithLl { global, .. } => global.to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// Those that are also set.
|
||||||
|
engine.register_get("local_pref", |route: &mut Route| {
|
||||||
|
optional(route.state().local_pref)
|
||||||
|
});
|
||||||
|
engine.register_set(
|
||||||
|
"local_pref",
|
||||||
|
|route: &mut Route, value: INT| -> Fallible<()> {
|
||||||
|
let value = number(value, "a LOCAL_PREF")?;
|
||||||
|
route.state().set_local_pref(Some(value));
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_set("local_pref", |route: &mut Route, (): ()| {
|
||||||
|
route.state().set_local_pref(None);
|
||||||
|
});
|
||||||
|
engine.register_get("med", |route: &mut Route| optional(route.state().med));
|
||||||
|
engine.register_set("med", |route: &mut Route, value: INT| -> Fallible<()> {
|
||||||
|
let value = number(value, "a MULTI_EXIT_DISC")?;
|
||||||
|
route.state().set_med(Some(value));
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
engine.register_set("med", |route: &mut Route, (): ()| {
|
||||||
|
route.state().set_med(None);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Communities.
|
||||||
|
engine.register_get("communities", |route: &mut Route| -> Array {
|
||||||
|
route
|
||||||
|
.state()
|
||||||
|
.communities
|
||||||
|
.iter()
|
||||||
|
.map(|community| Dynamic::from(format_standard(*community)))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
engine.register_fn(
|
||||||
|
"has_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<bool> {
|
||||||
|
let wanted = pattern(text)?;
|
||||||
|
Ok(route
|
||||||
|
.state()
|
||||||
|
.communities
|
||||||
|
.iter()
|
||||||
|
.any(|community| matches(wanted, *community)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"has_community",
|
||||||
|
|route: &mut Route, high: INT, low: INT| -> Fallible<bool> {
|
||||||
|
let wanted = (
|
||||||
|
number(high, "half a community")?,
|
||||||
|
number(low, "half a community")?,
|
||||||
|
);
|
||||||
|
Ok(route.state().communities.contains(&wanted))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"add_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<()> {
|
||||||
|
let community = exact(pattern(text)?, text)?;
|
||||||
|
route.state().add_community(community);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"add_community",
|
||||||
|
|route: &mut Route, high: INT, low: INT| -> Fallible<()> {
|
||||||
|
let community = (
|
||||||
|
number(high, "half a community")?,
|
||||||
|
number(low, "half a community")?,
|
||||||
|
);
|
||||||
|
route.state().add_community(community);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"remove_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<()> {
|
||||||
|
let unwanted = pattern(text)?;
|
||||||
|
route.state().remove_communities(unwanted);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"remove_community",
|
||||||
|
|route: &mut Route, high: INT, low: INT| -> Fallible<()> {
|
||||||
|
let unwanted = (
|
||||||
|
Some(number(high, "half a community")?),
|
||||||
|
Some(number(low, "half a community")?),
|
||||||
|
);
|
||||||
|
route.state().remove_communities(unwanted);
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Large communities.
|
||||||
|
engine.register_get("large_communities", |route: &mut Route| -> Array {
|
||||||
|
route
|
||||||
|
.state()
|
||||||
|
.large_communities
|
||||||
|
.iter()
|
||||||
|
.map(|community| Dynamic::from(format_large(community)))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
engine.register_fn(
|
||||||
|
"has_large_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<bool> {
|
||||||
|
let wanted = large_pattern(text)?;
|
||||||
|
Ok(route
|
||||||
|
.state()
|
||||||
|
.large_communities
|
||||||
|
.iter()
|
||||||
|
.any(|community| matches_large(wanted, community)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"add_large_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<()> {
|
||||||
|
let community = exact_large(large_pattern(text)?, text)?;
|
||||||
|
let mut state = route.state();
|
||||||
|
if !state.large_communities.contains(&community) {
|
||||||
|
state.large_communities.push(community);
|
||||||
|
state.changed.large_communities = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
engine.register_fn(
|
||||||
|
"remove_large_community",
|
||||||
|
|route: &mut Route, text: &str| -> Fallible<()> {
|
||||||
|
let unwanted = large_pattern(text)?;
|
||||||
|
let mut state = route.state();
|
||||||
|
let before = state.large_communities.len();
|
||||||
|
state
|
||||||
|
.large_communities
|
||||||
|
.retain(|community| !matches_large(unwanted, community));
|
||||||
|
state.changed.large_communities |= state.large_communities.len() != before;
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
//! Policies as an operator would write them, run on routes.
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bgp_packet::message::{
|
||||||
|
AsPathAttribute, AsPathSegment, CommunitiesPathAttribute, LargeCommunitiesPathAttribute,
|
||||||
|
LargeCommunity, LocalPrefPathAttribute, MultiExitDiscPathAttribute, NextHopPathAttribute,
|
||||||
|
OriginPathAttribute, PathAttribute,
|
||||||
|
};
|
||||||
|
use bgp_policy::{Direction, Peer, Policy, PolicyError, Subject, Verdict};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
|
||||||
|
fn prefix(s: &str) -> IpPrefix {
|
||||||
|
IpPrefix::try_from(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer(role: &str) -> Peer {
|
||||||
|
Peer::new(
|
||||||
|
"transit-a",
|
||||||
|
Ipv4Addr::new(192, 0, 2, 2).into(),
|
||||||
|
64602,
|
||||||
|
64601,
|
||||||
|
role,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A route by way of AS 64602 and 3356 from 15169, with communities of
|
||||||
|
/// 3356.
|
||||||
|
fn attributes() -> Vec<PathAttribute> {
|
||||||
|
vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![64602, 3356, 15169]),
|
||||||
|
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 2))),
|
||||||
|
PathAttribute::Communities(CommunitiesPathAttribute(vec![(3356, 3), (3356, 2012)])),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_on(
|
||||||
|
script: &str,
|
||||||
|
function: &str,
|
||||||
|
peer: &Peer,
|
||||||
|
route: &str,
|
||||||
|
attributes: &[PathAttribute],
|
||||||
|
direction: Direction,
|
||||||
|
) -> Result<Verdict, PolicyError> {
|
||||||
|
let policy = Policy::compile(script)?;
|
||||||
|
policy.hook(function)?.run(
|
||||||
|
peer,
|
||||||
|
&Subject {
|
||||||
|
prefix: &prefix(route),
|
||||||
|
attributes,
|
||||||
|
next_hop: None,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import(script: &str, route: &str, attributes: &[PathAttribute]) -> Verdict {
|
||||||
|
run_on(
|
||||||
|
script,
|
||||||
|
"policy",
|
||||||
|
&peer("provider"),
|
||||||
|
route,
|
||||||
|
attributes,
|
||||||
|
Direction::Import,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn communities(attributes: &[PathAttribute]) -> Vec<(u16, u16)> {
|
||||||
|
attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::Communities(communities) => Some(communities.0.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The policy this was built for: mark routes by where they were learned,
|
||||||
|
/// and prefer some to others.
|
||||||
|
#[test]
|
||||||
|
fn routes_are_marked_and_preferred_by_who_sent_them() {
|
||||||
|
let script = r#"
|
||||||
|
const LEARNED_FROM = #{ customer: "64601:1000", peer: "64601:2000", provider: "64601:3000" };
|
||||||
|
const PREFERENCE = #{ customer: 300, peer: 200, provider: 100 };
|
||||||
|
|
||||||
|
fn policy(route, peer) {
|
||||||
|
// What others marked their routes with means nothing here.
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = PREFERENCE[peer.role];
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let mut given = attributes();
|
||||||
|
given[3] = PathAttribute::Communities(CommunitiesPathAttribute(vec![
|
||||||
|
(3356, 3),
|
||||||
|
(64601, 1000),
|
||||||
|
(64601, 7),
|
||||||
|
]));
|
||||||
|
for (role, mark, preference) in [
|
||||||
|
("customer", 1000, 300),
|
||||||
|
("peer", 2000, 200),
|
||||||
|
("provider", 3000, 100),
|
||||||
|
] {
|
||||||
|
let verdict = run_on(
|
||||||
|
script,
|
||||||
|
"policy",
|
||||||
|
&peer(role),
|
||||||
|
"203.0.113.0/24",
|
||||||
|
&given,
|
||||||
|
Direction::Import,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(verdict.accept);
|
||||||
|
assert!(!verdict.saw_prefix, "the answer is good for any prefix");
|
||||||
|
let changed = verdict.attributes.expect("the route was changed");
|
||||||
|
assert_eq!(communities(&changed), [(3356, 3), (64601, mark)], "{role}");
|
||||||
|
assert!(
|
||||||
|
changed.contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(
|
||||||
|
preference
|
||||||
|
))),
|
||||||
|
"{role}: {changed:?}"
|
||||||
|
);
|
||||||
|
// In the order of their type codes: LOCAL_PREF (5) before
|
||||||
|
// COMMUNITIES (8).
|
||||||
|
let codes: Vec<u8> = changed.iter().map(PathAttribute::type_code).collect();
|
||||||
|
assert_eq!(codes, [1, 2, 3, 5, 8]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_policy_refuses_by_what_a_route_carries() {
|
||||||
|
let script = r#"
|
||||||
|
const BLACKHOLE = "65535:666";
|
||||||
|
fn policy(route, peer) {
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
if route.has_community("3356:*") && route.path_has(174) { return false; }
|
||||||
|
if route.as_path_len > 5 || route.origin_as == 64512 { return false; }
|
||||||
|
if route.origin != "igp" { return false; }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let verdict = import(script, "203.0.113.0/24", &attributes());
|
||||||
|
assert_eq!(
|
||||||
|
verdict,
|
||||||
|
Verdict {
|
||||||
|
accept: true,
|
||||||
|
attributes: None,
|
||||||
|
saw_prefix: false
|
||||||
|
},
|
||||||
|
"nothing to object to, and nothing changed"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut blackholed = attributes();
|
||||||
|
blackholed[3] = PathAttribute::Communities(CommunitiesPathAttribute(vec![(65535, 666)]));
|
||||||
|
assert!(!import(script, "203.0.113.0/24", &blackholed).accept);
|
||||||
|
|
||||||
|
let mut by_cogent = attributes();
|
||||||
|
by_cogent[1] = AsPathAttribute::from_asns(vec![64602, 174, 15169]);
|
||||||
|
assert!(!import(script, "203.0.113.0/24", &by_cogent).accept);
|
||||||
|
|
||||||
|
let mut long = attributes();
|
||||||
|
long[1] = AsPathAttribute::from_asns(vec![64602; 6]);
|
||||||
|
assert!(!import(script, "203.0.113.0/24", &long).accept);
|
||||||
|
|
||||||
|
let mut incomplete = attributes();
|
||||||
|
incomplete[0] = PathAttribute::Origin(OriginPathAttribute::INCOMPLETE);
|
||||||
|
assert!(!import(script, "203.0.113.0/24", &incomplete).accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function that asks for the prefix is asked about every prefix; the
|
||||||
|
/// verdict says that it did.
|
||||||
|
#[test]
|
||||||
|
fn a_policy_that_looks_at_the_prefix_says_so() {
|
||||||
|
let script = r#"
|
||||||
|
fn policy(route, peer) {
|
||||||
|
if route.family == "ipv6" { return route.prefix_len <= 48; }
|
||||||
|
if route.within("203.0.112.0/22") { route.local_pref = 50; }
|
||||||
|
route.prefix != "198.51.100.0/24"
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let inside = import(script, "203.0.113.0/24", &attributes());
|
||||||
|
assert!(inside.accept && inside.saw_prefix);
|
||||||
|
assert!(
|
||||||
|
inside
|
||||||
|
.attributes
|
||||||
|
.unwrap()
|
||||||
|
.contains(&PathAttribute::LocalPref(LocalPrefPathAttribute(50)))
|
||||||
|
);
|
||||||
|
let outside = import(script, "192.0.2.0/24", &attributes());
|
||||||
|
assert_eq!(
|
||||||
|
outside,
|
||||||
|
Verdict {
|
||||||
|
accept: true,
|
||||||
|
attributes: None,
|
||||||
|
saw_prefix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(!import(script, "198.51.100.0/24", &attributes()).accept);
|
||||||
|
assert!(import(script, "2001:db8::/48", &attributes()).accept);
|
||||||
|
assert!(!import(script, "2001:db8::/56", &attributes()).accept);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_about_a_route_and_a_peer_can_be_read() {
|
||||||
|
let script = r#"
|
||||||
|
fn policy(route, peer) {
|
||||||
|
if route.as_path != [64602, 3356, 15169] { throw "as_path"; }
|
||||||
|
if route.as_path_len != 3 { throw "as_path_len"; }
|
||||||
|
if route.neighbor_as != 64602 || route.origin_as != 15169 { throw "ends"; }
|
||||||
|
if route.next_hop != "192.0.2.2" { throw "next_hop " + route.next_hop; }
|
||||||
|
if route.communities != ["3356:3", "3356:2012"] { throw "communities"; }
|
||||||
|
if !route.has_community(3356, 3) || route.has_community(3356, 4) { throw "has"; }
|
||||||
|
if route.large_communities != ["64601:1:2"] { throw "large"; }
|
||||||
|
if !route.has_large_community("64601:*:2") { throw "has large"; }
|
||||||
|
if route.med != 70 || route.local_pref != () { throw "numbers"; }
|
||||||
|
if peer.name != "transit-a" || peer.asn != 64602 || peer.local_asn != 64601 {
|
||||||
|
throw "peer";
|
||||||
|
}
|
||||||
|
if peer.address != "192.0.2.2" || peer.role != "provider" || peer.ibgp {
|
||||||
|
throw "peer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let mut given = attributes();
|
||||||
|
given.push(PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(70)));
|
||||||
|
given.push(PathAttribute::LargeCommunities(
|
||||||
|
LargeCommunitiesPathAttribute {
|
||||||
|
communities: vec![LargeCommunity {
|
||||||
|
global_admin: 64601,
|
||||||
|
data_1: 1,
|
||||||
|
data_2: 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
));
|
||||||
|
// It ends without returning anything: no objection.
|
||||||
|
let verdict = import(script, "203.0.113.0/24", &given);
|
||||||
|
assert!(verdict.accept && verdict.attributes.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn everything_that_can_be_changed_is_changed_only_if_it_differs() {
|
||||||
|
let script = r#"
|
||||||
|
fn same(route, peer) {
|
||||||
|
route.add_community("3356:3");
|
||||||
|
route.remove_community("1:1");
|
||||||
|
route.remove_large_community("1:*:*");
|
||||||
|
route.local_pref = ();
|
||||||
|
route.med = ();
|
||||||
|
}
|
||||||
|
fn other(route, peer) {
|
||||||
|
route.remove_community("*:*");
|
||||||
|
route.add_large_community("64601:1:2");
|
||||||
|
route.add_large_community("64601:1:2");
|
||||||
|
route.med = 5;
|
||||||
|
route.local_pref = 150;
|
||||||
|
route.local_pref = ();
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let policy = Policy::compile(script).unwrap();
|
||||||
|
let ask = |function: &str| {
|
||||||
|
policy
|
||||||
|
.hook(function)
|
||||||
|
.unwrap()
|
||||||
|
.run(
|
||||||
|
&peer(""),
|
||||||
|
&Subject {
|
||||||
|
prefix: &prefix("203.0.113.0/24"),
|
||||||
|
attributes: &attributes(),
|
||||||
|
next_hop: None,
|
||||||
|
direction: Direction::Import,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert_eq!(ask("same").attributes, None);
|
||||||
|
let changed = ask("other").attributes.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
changed,
|
||||||
|
[
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![64602, 3356, 15169]),
|
||||||
|
PathAttribute::NextHop(NextHopPathAttribute(Ipv4Addr::new(192, 0, 2, 2))),
|
||||||
|
PathAttribute::MultiExitDisc(MultiExitDiscPathAttribute(5)),
|
||||||
|
PathAttribute::LargeCommunities(LargeCommunitiesPathAttribute {
|
||||||
|
communities: vec![LargeCommunity {
|
||||||
|
global_admin: 64601,
|
||||||
|
data_1: 1,
|
||||||
|
data_2: 2
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
"no communities left, a MED and a large community added, LOCAL_PREF as it was"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On the way out a policy says what a peer is told, and how.
|
||||||
|
#[test]
|
||||||
|
fn a_route_is_announced_with_what_the_policy_gives_it() {
|
||||||
|
let script = r#"
|
||||||
|
fn to_peer(route, peer) {
|
||||||
|
if route.has_community("64601:3000") { return false; } // learned from a provider
|
||||||
|
if peer.name == "transit-a" { route.prepend(2); }
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let ours = |communities: Vec<(u16, u16)>| {
|
||||||
|
vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![64601]),
|
||||||
|
PathAttribute::Communities(CommunitiesPathAttribute(communities)),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let export = |attributes: &[PathAttribute]| {
|
||||||
|
run_on(
|
||||||
|
script,
|
||||||
|
"to_peer",
|
||||||
|
&peer("peer"),
|
||||||
|
"193.0.0.0/21",
|
||||||
|
attributes,
|
||||||
|
Direction::Export,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert!(!export(&ours(vec![(64601, 3000)])).accept);
|
||||||
|
let verdict = export(&ours(vec![(64601, 1000), (64601, 9)]));
|
||||||
|
assert_eq!(
|
||||||
|
verdict.attributes.unwrap(),
|
||||||
|
[
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![64601, 64601, 64601]),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepending to a path that begins with a set, or is empty.
|
||||||
|
let set_first = vec![PathAttribute::AsPath(AsPathAttribute {
|
||||||
|
segments: vec![AsPathSegment {
|
||||||
|
ordered: false,
|
||||||
|
path: vec![1, 2],
|
||||||
|
}],
|
||||||
|
})];
|
||||||
|
let prepended = run_on(
|
||||||
|
"fn f(route, peer) { route.prepend(1); }",
|
||||||
|
"f",
|
||||||
|
&peer(""),
|
||||||
|
"193.0.0.0/21",
|
||||||
|
&set_first,
|
||||||
|
Direction::Export,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
prepended.attributes.unwrap(),
|
||||||
|
[PathAttribute::AsPath(AsPathAttribute {
|
||||||
|
segments: vec![
|
||||||
|
AsPathSegment {
|
||||||
|
ordered: true,
|
||||||
|
path: vec![64601]
|
||||||
|
},
|
||||||
|
AsPathSegment {
|
||||||
|
ordered: false,
|
||||||
|
path: vec![1, 2]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function that fails has refused the route: the caller is told what
|
||||||
|
/// went wrong, and has nothing to take.
|
||||||
|
#[test]
|
||||||
|
fn a_policy_that_fails_gives_no_verdict() {
|
||||||
|
let failure = |script: &str| {
|
||||||
|
run_on(
|
||||||
|
script,
|
||||||
|
"policy",
|
||||||
|
&peer("provider"),
|
||||||
|
"203.0.113.0/24",
|
||||||
|
&attributes(),
|
||||||
|
Direction::Import,
|
||||||
|
)
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.add_community(\"3356\"); }",
|
||||||
|
"is not a community",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.add_community(\"3356:*\"); }",
|
||||||
|
"stands for more than one",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.add_community(70000, 1); }",
|
||||||
|
"70000 is not half a community",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.local_pref = -1; }",
|
||||||
|
"-1 is not a LOCAL_PREF",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.within(\"nowhere\") }",
|
||||||
|
"is not a prefix",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { route.prepend(1); }",
|
||||||
|
"prepend is for routes on their way out",
|
||||||
|
),
|
||||||
|
("fn policy(route, peer) { route.nonsense }", "nonsense"),
|
||||||
|
("fn policy(route, peer) { throw \"no\"; }", "no"),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { 7 }",
|
||||||
|
"returned 7; a policy returns true or false",
|
||||||
|
),
|
||||||
|
// One that never ends is stopped.
|
||||||
|
("fn policy(route, peer) { loop { } }", "operations"),
|
||||||
|
(
|
||||||
|
"fn policy(route, peer) { policy(route, peer) }",
|
||||||
|
"Stack overflow",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (script, complaint) in cases {
|
||||||
|
let said = failure(script);
|
||||||
|
assert!(said.contains(complaint), "{script}: {said}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_script_that_is_wrong_is_found_when_it_is_loaded() {
|
||||||
|
let load = |script: &str, function: &str| -> PolicyError {
|
||||||
|
match Policy::compile(script) {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(policy) => policy.hook(function).unwrap_err(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
load("fn policy(route, peer) {", "policy"),
|
||||||
|
PolicyError::Parse(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
load("let x = 1 / 0;", "policy"),
|
||||||
|
PolicyError::Load(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
load(
|
||||||
|
"fn from_a(r, p) { true } fn from_b(r, p) { true }",
|
||||||
|
"from_c"
|
||||||
|
)
|
||||||
|
.to_string(),
|
||||||
|
"the script has no function \"from_c\"; it has: from_a, from_b"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
load("fn policy(route) { true }", "policy").to_string(),
|
||||||
|
"the function \"policy\" takes 1 arguments; a policy takes two, the route and the peer"
|
||||||
|
);
|
||||||
|
// Nothing of the machine is within reach of a script.
|
||||||
|
for out_of_reach in [
|
||||||
|
"import \"other\" as other;",
|
||||||
|
"fn policy(route, peer) { open_file(\"/etc/passwd\") }",
|
||||||
|
"fn policy(route, peer) { let t = timestamp(); true }",
|
||||||
|
] {
|
||||||
|
let policy = Policy::compile(out_of_reach).and_then(|policy| {
|
||||||
|
policy.hook("policy").and_then(|hook| {
|
||||||
|
hook.run(
|
||||||
|
&peer(""),
|
||||||
|
&Subject {
|
||||||
|
prefix: &prefix("203.0.113.0/24"),
|
||||||
|
attributes: &attributes(),
|
||||||
|
next_hop: None,
|
||||||
|
direction: Direction::Import,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert!(policy.is_err(), "{out_of_reach}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constants are for every function of the script, also one that another
|
||||||
|
/// calls; and many sessions use one script at once.
|
||||||
|
#[test]
|
||||||
|
fn a_script_is_shared_by_every_session() {
|
||||||
|
let script = r#"
|
||||||
|
const TAGS = ["64601:1", "64601:2", "64601:3"];
|
||||||
|
fn tag(route, n) { route.add_community(TAGS[n]); }
|
||||||
|
fn policy(route, peer) { tag(route, peer.asn % 3); true }
|
||||||
|
"#;
|
||||||
|
let policy = Policy::compile(script).unwrap();
|
||||||
|
let hook = policy.hook("policy").unwrap();
|
||||||
|
assert_eq!(policy.functions(), ["policy", "tag"]);
|
||||||
|
let threads: Vec<_> = (0..8u32)
|
||||||
|
.map(|n| {
|
||||||
|
let hook = hook.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let peer = Peer::new(
|
||||||
|
"p",
|
||||||
|
Ipv4Addr::new(192, 0, 2, 9).into(),
|
||||||
|
64700 + n,
|
||||||
|
64601,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
for _ in 0..2000 {
|
||||||
|
let verdict = hook
|
||||||
|
.run(
|
||||||
|
&peer,
|
||||||
|
&Subject {
|
||||||
|
prefix: &prefix("203.0.113.0/24"),
|
||||||
|
attributes: &attributes(),
|
||||||
|
next_hop: None,
|
||||||
|
direction: Direction::Import,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let expected = (64601, 1 + ((64700 + n) % 3) as u16);
|
||||||
|
assert_eq!(
|
||||||
|
communities(&verdict.attributes.unwrap()).last(),
|
||||||
|
Some(&expected)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for thread in threads {
|
||||||
|
thread.join().unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(Arc::strong_count(&policy), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The example operators start from does what its comments say.
|
||||||
|
#[test]
|
||||||
|
fn the_example_script_does_what_it_says() {
|
||||||
|
let script = include_str!("../../../testdata/policy.rhai");
|
||||||
|
let policy = Policy::compile(script).unwrap();
|
||||||
|
let from_peer = policy.hook("from_peer").unwrap();
|
||||||
|
let to_peer = policy.hook("to_peer").unwrap();
|
||||||
|
let them = |name: &str, role: &str| {
|
||||||
|
Peer::new(name, Ipv4Addr::new(192, 0, 2, 2).into(), 64602, 65000, role)
|
||||||
|
};
|
||||||
|
let with = |communities: Vec<(u16, u16)>| {
|
||||||
|
vec![
|
||||||
|
PathAttribute::Origin(OriginPathAttribute::IGP),
|
||||||
|
AsPathAttribute::from_asns(vec![64602]),
|
||||||
|
PathAttribute::Communities(CommunitiesPathAttribute(communities)),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
let ask = |hook: &bgp_policy::Hook, peer: &Peer, attributes: &[PathAttribute], direction| {
|
||||||
|
hook.run(
|
||||||
|
peer,
|
||||||
|
&Subject {
|
||||||
|
prefix: &prefix("203.0.113.0/24"),
|
||||||
|
attributes,
|
||||||
|
next_hop: None,
|
||||||
|
direction,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let local_pref = |attributes: &[PathAttribute]| {
|
||||||
|
attributes.iter().find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::LocalPref(local_pref) => Some(local_pref.0),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// In: marked and ranked by who it is from, whatever it was marked with.
|
||||||
|
for (role, mark, preference) in [
|
||||||
|
("customer", 1000, 300),
|
||||||
|
("peer", 2000, 200),
|
||||||
|
("provider", 3000, 100),
|
||||||
|
] {
|
||||||
|
let verdict = ask(
|
||||||
|
&from_peer,
|
||||||
|
&them("r", role),
|
||||||
|
&with(vec![(65000, 1000), (3356, 3)]),
|
||||||
|
Direction::Import,
|
||||||
|
);
|
||||||
|
let changed = verdict.attributes.unwrap();
|
||||||
|
assert_eq!(communities(&changed), [(3356, 3), (65000, mark)], "{role}");
|
||||||
|
assert_eq!(local_pref(&changed), Some(preference), "{role}");
|
||||||
|
}
|
||||||
|
// A peer with no role is taken, with our marks removed and no more.
|
||||||
|
let verdict = ask(
|
||||||
|
&from_peer,
|
||||||
|
&them("r", ""),
|
||||||
|
&with(vec![(65000, 7)]),
|
||||||
|
Direction::Import,
|
||||||
|
);
|
||||||
|
assert!(verdict.accept);
|
||||||
|
assert_eq!(communities(&verdict.attributes.unwrap()), []);
|
||||||
|
// The peer's own "last resort" mark, and a blackhole request.
|
||||||
|
let verdict = ask(
|
||||||
|
&from_peer,
|
||||||
|
&them("r", "peer"),
|
||||||
|
&with(vec![(64602, 80)]),
|
||||||
|
Direction::Import,
|
||||||
|
);
|
||||||
|
assert_eq!(local_pref(&verdict.attributes.unwrap()), Some(80));
|
||||||
|
assert!(
|
||||||
|
!ask(
|
||||||
|
&from_peer,
|
||||||
|
&them("r", "peer"),
|
||||||
|
&with(vec![(65535, 666)]),
|
||||||
|
Direction::Import
|
||||||
|
)
|
||||||
|
.accept
|
||||||
|
);
|
||||||
|
|
||||||
|
// Out: what came from above goes to customers only, without our marks.
|
||||||
|
let from_provider = with(vec![(65000, 3000), (3356, 3)]);
|
||||||
|
assert!(
|
||||||
|
!ask(
|
||||||
|
&to_peer,
|
||||||
|
&them("r", "peer"),
|
||||||
|
&from_provider,
|
||||||
|
Direction::Export
|
||||||
|
)
|
||||||
|
.accept
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!ask(
|
||||||
|
&to_peer,
|
||||||
|
&them("r", "provider"),
|
||||||
|
&from_provider,
|
||||||
|
Direction::Export
|
||||||
|
)
|
||||||
|
.accept
|
||||||
|
);
|
||||||
|
let verdict = ask(
|
||||||
|
&to_peer,
|
||||||
|
&them("r", "customer"),
|
||||||
|
&from_provider,
|
||||||
|
Direction::Export,
|
||||||
|
);
|
||||||
|
assert_eq!(communities(&verdict.attributes.unwrap()), [(3356, 3)]);
|
||||||
|
assert!(
|
||||||
|
!ask(
|
||||||
|
&to_peer,
|
||||||
|
&them("r", "customer"),
|
||||||
|
&with(vec![(65535, 65281)]),
|
||||||
|
Direction::Export
|
||||||
|
)
|
||||||
|
.accept
|
||||||
|
);
|
||||||
|
// And the path is longer to the one it should be.
|
||||||
|
let verdict = ask(
|
||||||
|
&to_peer,
|
||||||
|
&them("backup-transit", "provider"),
|
||||||
|
&with(vec![]),
|
||||||
|
Direction::Export,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
verdict
|
||||||
|
.attributes
|
||||||
|
.unwrap()
|
||||||
|
.contains(&AsPathAttribute::from_asns(vec![65000, 65000, 64602]))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -430,11 +430,11 @@ impl RibTable {
|
|||||||
{
|
{
|
||||||
let mut outcome = Outcome::default();
|
let mut outcome = Outcome::default();
|
||||||
self.by_shard(routes, |shard, placed| {
|
self.by_shard(routes, |shard, placed| {
|
||||||
let Some(info) = shard.peers.get(peer.as_usize()).copied().flatten() else {
|
let Some(_) = shard.peers.get(peer.as_usize()).copied().flatten() else {
|
||||||
// The peer was removed while this was on its way.
|
// The peer was removed while this was on its way.
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let preference = select::preference(attrs.key(), &info);
|
let preference = select::preference(attrs.key());
|
||||||
for (hash, nlri) in placed {
|
for (hash, nlri) in placed {
|
||||||
outcome += shard.announce(*hash, *nlri, peer, preference, attrs);
|
outcome += shard.announce(*hash, *nlri, peer, preference, attrs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,12 @@ use super::{DEFAULT_LOCAL_PREF, PeerInfo, Row};
|
|||||||
/// 12 bits and the ORIGIN in 2, each turned upside down so that shorter and
|
/// 12 bits and the ORIGIN in 2, each turned upside down so that shorter and
|
||||||
/// lower compare as larger. Twelve bits are enough for any path: a BGP
|
/// lower compare as larger. Twelve bits are enough for any path: a BGP
|
||||||
/// message of 4096 octets has room for about a thousand AS numbers.
|
/// message of 4096 octets has room for about a thousand AS numbers.
|
||||||
pub(super) fn preference(key: &SelectionKey, peer: &PeerInfo) -> u64 {
|
pub(super) fn preference(key: &SelectionKey) -> u64 {
|
||||||
// LOCAL_PREF is a matter between routers of one AS. A route from an
|
// LOCAL_PREF is a matter between routers of one AS. What an external
|
||||||
// external peer has the default until import policy gives it another.
|
// peer sent as one never gets as far as the table (the decoder drops
|
||||||
let local_pref = if peer.ibgp {
|
// it, RFC 7606 §7.5); a route from such a peer that has one was given
|
||||||
key.local_pref.unwrap_or(DEFAULT_LOCAL_PREF)
|
// it by our own import policy, which is who is to say.
|
||||||
} else {
|
let local_pref = key.local_pref.unwrap_or(DEFAULT_LOCAL_PREF);
|
||||||
DEFAULT_LOCAL_PREF
|
|
||||||
};
|
|
||||||
const LONGEST: u16 = (1 << 12) - 1;
|
const LONGEST: u16 = (1 << 12) - 1;
|
||||||
(u64::from(local_pref) << 16)
|
(u64::from(local_pref) << 16)
|
||||||
| (u64::from(LONGEST - key.as_path_len.min(LONGEST)) << 4)
|
| (u64::from(LONGEST - key.as_path_len.min(LONGEST)) << 4)
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ fn each_step_of_selection_decides_when_the_ones_before_tie() {
|
|||||||
let p = prefix("10.0.0.0/8");
|
let p = prefix("10.0.0.0/8");
|
||||||
// Peer 0 has the lower identifier, so it wins whenever nothing else
|
// Peer 0 has the lower identifier, so it wins whenever nothing else
|
||||||
// decides; each case makes peer 1's route better in one step.
|
// decides; each case makes peer 1's route better in one step.
|
||||||
let cases: [(&str, [PeerInfo; 2], Route, Route); 6] = [
|
let cases: [(&str, [PeerInfo; 2], Route, Route); 7] = [
|
||||||
(
|
(
|
||||||
"higher LOCAL_PREF, despite a longer path",
|
"higher LOCAL_PREF, despite a longer path",
|
||||||
[internal(1), internal(2)],
|
[internal(1), internal(2)],
|
||||||
@@ -183,6 +183,18 @@ fn each_step_of_selection_decides_when_the_ones_before_tie() {
|
|||||||
..Route::via(&[1, 2, 3])
|
..Route::via(&[1, 2, 3])
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"higher LOCAL_PREF from an external peer too, where policy set one",
|
||||||
|
[external(1), external(2)],
|
||||||
|
Route {
|
||||||
|
local_pref: Some(100),
|
||||||
|
..Route::via(&[1])
|
||||||
|
},
|
||||||
|
Route {
|
||||||
|
local_pref: Some(200),
|
||||||
|
..Route::via(&[1, 2, 3])
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"shorter AS path",
|
"shorter AS path",
|
||||||
[external(1), external(2)],
|
[external(1), external(2)],
|
||||||
@@ -534,13 +546,9 @@ fn chosen_plainly(
|
|||||||
routes: &BTreeMap<(usize, u32), Route>,
|
routes: &BTreeMap<(usize, u32), Route>,
|
||||||
peers: &[PeerInfo],
|
peers: &[PeerInfo],
|
||||||
) -> Option<(usize, u32)> {
|
) -> Option<(usize, u32)> {
|
||||||
let local_pref = |peer: usize, route: &Route| {
|
// Whoever the route is from: an external peer's own LOCAL_PREF never
|
||||||
if peers[peer].ibgp {
|
// gets into a table, and one that is there was put there by policy.
|
||||||
route.local_pref.unwrap_or(DEFAULT_LOCAL_PREF)
|
let local_pref = |_peer: usize, route: &Route| route.local_pref.unwrap_or(DEFAULT_LOCAL_PREF);
|
||||||
} else {
|
|
||||||
DEFAULT_LOCAL_PREF
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut left: Vec<((usize, u32), &Route)> =
|
let mut left: Vec<((usize, u32), &Route)> =
|
||||||
routes.iter().map(|(k, route)| (*k, route)).collect();
|
routes.iter().map(|(k, route)| (*k, route)).collect();
|
||||||
let most = left.iter().map(|(k, r)| local_pref(k.0, r)).max()?;
|
let most = left.iter().map(|(k, r)| local_pref(k.0, r)).max()?;
|
||||||
@@ -761,8 +769,7 @@ fn a_row_can_hold_more_routes_than_a_sixteen_bit_index_counts() {
|
|||||||
let info = external(1);
|
let info = external(1);
|
||||||
let peers = [Some(info)];
|
let peers = [Some(info)];
|
||||||
let peer = PeerIndex(0);
|
let peer = PeerIndex(0);
|
||||||
let path =
|
let path = |attrs: &Arc<AttrSet>| Path::new(select::preference(attrs.key()), peer, attrs);
|
||||||
|attrs: &Arc<AttrSet>| Path::new(select::preference(attrs.key(), &info), peer, attrs);
|
|
||||||
let seqs = journal::Journals::new(journal::Limits::default()).appeared(prefix("10.0.0.0/8"));
|
let seqs = journal::Journals::new(journal::Limits::default()).appeared(prefix("10.0.0.0/8"));
|
||||||
|
|
||||||
let mut row = Row::new(prefix("10.0.0.0/8"), path(&worse), 1, seqs);
|
let mut row = Row::new(prefix("10.0.0.0/8"), path(&worse), 1, seqs);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ bgp-packet.workspace = true
|
|||||||
bgp-primitives.workspace = true
|
bgp-primitives.workspace = true
|
||||||
bgp-rib.workspace = true
|
bgp-rib.workspace = true
|
||||||
bgp-fib.workspace = true
|
bgp-fib.workspace = true
|
||||||
|
bgp-policy.workspace = true
|
||||||
bgp-rpki.workspace = true
|
bgp-rpki.workspace = true
|
||||||
bytes.workspace = true
|
bytes.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
|
|||||||
@@ -53,6 +53,49 @@ message BgpConfig {
|
|||||||
// that the machine forwards packets by them. Left out, the daemon only
|
// that the machine forwards packets by them. Left out, the daemon only
|
||||||
// speaks BGP and the machine's forwarding is not touched.
|
// speaks BGP and the machine's forwarding is not touched.
|
||||||
Kernel kernel = 11;
|
Kernel kernel = 11;
|
||||||
|
|
||||||
|
// The operator's own rules for routes, as a script. Left out, routes are
|
||||||
|
// judged by the checks of each peer's import and by nothing else.
|
||||||
|
PolicyScript policy = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A script with the operator's own rules: which routes are taken and
|
||||||
|
// announced beyond what the general checks say, and what they are marked
|
||||||
|
// with. It is written in Rhai (https://rhai.rs) as functions that are
|
||||||
|
// given a route and the peer it comes from or goes to, may change the
|
||||||
|
// route, and return whether it is taken:
|
||||||
|
//
|
||||||
|
// const BLACKHOLE = "65535:666";
|
||||||
|
//
|
||||||
|
// fn from_transit(route, peer) {
|
||||||
|
// if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
// route.add_community("64601:3000"); // learned from a provider
|
||||||
|
// route.local_pref = 80;
|
||||||
|
// true
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// What a function can read and change is listed in bin/bgp/README.md. A
|
||||||
|
// peer says which function is for its routes (import.function and
|
||||||
|
// export.function); the two names here are for peers that say nothing.
|
||||||
|
//
|
||||||
|
// The script is read when the daemon starts, and a script that does not
|
||||||
|
// parse, or a function that is named and not there, stops it from
|
||||||
|
// starting; `bgp --check` finds both. A function that fails on a route
|
||||||
|
// (it asks for something that is not there, or does not end) has refused
|
||||||
|
// that route.
|
||||||
|
message PolicyScript {
|
||||||
|
// The file the script is in; if not absolute, beside the configuration
|
||||||
|
// file.
|
||||||
|
string path = 1;
|
||||||
|
|
||||||
|
// Or the script itself. One of the two.
|
||||||
|
string source = 2;
|
||||||
|
|
||||||
|
// The function for routes from peers that name none; none if empty.
|
||||||
|
string import_function = 3;
|
||||||
|
|
||||||
|
// The function for routes to peers that name none; none if empty.
|
||||||
|
string export_function = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installing routes in the kernel. Linux only; the daemon needs the
|
// Installing routes in the kernel. Linux only; the daemon needs the
|
||||||
@@ -211,6 +254,23 @@ message Peer {
|
|||||||
// that says nothing (one that does not implement RFC 9234) is taken at
|
// that says nothing (one that does not implement RFC 9234) is taken at
|
||||||
// this configuration's word for it. Needs role.
|
// this configuration's word for it. Needs role.
|
||||||
bool require_role = 10;
|
bool require_role = 10;
|
||||||
|
|
||||||
|
// What is announced to the peer, and how.
|
||||||
|
ExportPolicy export = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What is announced to a peer.
|
||||||
|
//
|
||||||
|
// The daemon announces the routes it originates; it does not yet pass on
|
||||||
|
// what it learned from other peers. When it does, those routes will be
|
||||||
|
// given to the same function.
|
||||||
|
message ExportPolicy {
|
||||||
|
// The function of the policy script (see PolicyScript) that every route
|
||||||
|
// on its way to the peer is given. It may refuse the route, which is
|
||||||
|
// then not announced, or change its communities, its MED, and how often
|
||||||
|
// our AS is on its path. Empty for the script's export_function, or for
|
||||||
|
// none.
|
||||||
|
string function = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a peer is to us.
|
// What a peer is to us.
|
||||||
@@ -296,6 +356,12 @@ message ImportPolicy {
|
|||||||
// to. Not needed for a peer whose role is ROUTE_SERVER, whose routes
|
// to. Not needed for a peer whose role is ROUTE_SERVER, whose routes
|
||||||
// have its clients as next hops.
|
// have its clients as next hops.
|
||||||
bool accept_third_party_next_hop = 14;
|
bool accept_third_party_next_hop = 14;
|
||||||
|
|
||||||
|
// The function of the policy script (see PolicyScript) that every route
|
||||||
|
// from the peer is given, after it has passed all of the above. It may
|
||||||
|
// refuse the route, or change its communities, its LOCAL_PREF and its
|
||||||
|
// MED. Empty for the script's import_function, or for none.
|
||||||
|
string function = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A prefix, and optionally the more specific prefixes inside it.
|
// A prefix, and optionally the more specific prefixes inside it.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ use bgp_packet::message::{
|
|||||||
LocalPrefPathAttribute, Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute,
|
LocalPrefPathAttribute, Message, MpReachNlriPathAttribute, MpUnreachNlriPathAttribute,
|
||||||
MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
|
MultiExitDiscPathAttribute, NextHopPathAttribute, NlriNextHop, PathAttribute, UpdateMessage,
|
||||||
};
|
};
|
||||||
|
use bgp_policy::{Direction, Hook, PolicyError, Subject};
|
||||||
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
||||||
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
use bgp_primitives::ip_prefix::{IpPrefix, Nlri};
|
||||||
use bgp_rib::table::DEFAULT_LOCAL_PREF;
|
use bgp_rib::table::DEFAULT_LOCAL_PREF;
|
||||||
@@ -34,7 +35,9 @@ use tokio::sync::broadcast::error::RecvError;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::metrics::POLICY_RUNS;
|
||||||
use crate::policy::PeerRole;
|
use crate::policy::PeerRole;
|
||||||
|
use crate::routes::policy_failed;
|
||||||
use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry};
|
use crate::session::{PeerId, SessionClosed, SessionEvent, SessionHandle, SessionRegistry};
|
||||||
|
|
||||||
/// Starts the advertiser. It hears of every session that comes up after
|
/// Starts the advertiser. It hears of every session that comes up after
|
||||||
@@ -235,7 +238,7 @@ impl Exporter {
|
|||||||
role: session.role,
|
role: session.role,
|
||||||
extended_next_hop: session.extended_next_hop,
|
extended_next_hop: session.extended_next_hop,
|
||||||
};
|
};
|
||||||
let Some(update) = build_announce(route, &towards) else {
|
let Some(mut update) = build_announce(route, &towards) else {
|
||||||
debug!(
|
debug!(
|
||||||
peer = %session.peer,
|
peer = %session.peer,
|
||||||
prefix = %route.prefix,
|
prefix = %route.prefix,
|
||||||
@@ -243,6 +246,28 @@ impl Exporter {
|
|||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
// The operator's own rules for what this peer is told.
|
||||||
|
if let Some(export) = &session.export {
|
||||||
|
let (hook, peer) = &**export;
|
||||||
|
POLICY_RUNS.with_label_values(&["export"]).inc();
|
||||||
|
match apply_export(&mut update, hook, peer, &route.prefix, &towards) {
|
||||||
|
Ok(true) => {}
|
||||||
|
Ok(false) => {
|
||||||
|
debug!(
|
||||||
|
peer = %session.peer,
|
||||||
|
prefix = %route.prefix,
|
||||||
|
function = hook.name(),
|
||||||
|
"Not announcing route: the policy function refused it"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// A function that fails has refused the route.
|
||||||
|
Err(e) => {
|
||||||
|
policy_failed(hook.name(), session.name.as_deref().unwrap_or(""), &e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
|
debug!(peer = %session.peer, prefix = %route.prefix, "Announcing local route");
|
||||||
session.send(Message::Update(update)).await?;
|
session.send(Message::Update(update)).await?;
|
||||||
self.announced.insert(route.prefix);
|
self.announced.insert(route.prefix);
|
||||||
@@ -265,6 +290,59 @@ impl Exporter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asks the policy function `hook` about the route for `prefix` that
|
||||||
|
/// `update` announces, and makes `update` what the function left of it.
|
||||||
|
/// Returns whether the route is to be announced at all.
|
||||||
|
fn apply_export(
|
||||||
|
update: &mut UpdateMessage,
|
||||||
|
hook: &Hook,
|
||||||
|
peer: &bgp_policy::Peer,
|
||||||
|
prefix: &IpPrefix,
|
||||||
|
towards: &Towards,
|
||||||
|
) -> Result<bool, PolicyError> {
|
||||||
|
let next_hop = update
|
||||||
|
.path_attributes
|
||||||
|
.iter()
|
||||||
|
.find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::MpReachNlri(mp) => Some(mp.next_hop),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let verdict = hook.run(
|
||||||
|
peer,
|
||||||
|
&Subject {
|
||||||
|
prefix,
|
||||||
|
attributes: &update.path_attributes,
|
||||||
|
next_hop,
|
||||||
|
direction: Direction::Export,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if !verdict.accept {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let Some(mut attributes) = verdict.attributes else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
// What the function may not decide. LOCAL_PREF does not leave the AS
|
||||||
|
// (RFC 4271 §5.1.5); and a path it made longer is still to reach a
|
||||||
|
// peer with two-octet AS numbers whole (RFC 6793 §4.2.2).
|
||||||
|
attributes.retain(|attribute| match attribute {
|
||||||
|
PathAttribute::LocalPref(_) => towards.ibgp,
|
||||||
|
PathAttribute::As4Path(_) => false,
|
||||||
|
_ => true,
|
||||||
|
});
|
||||||
|
if !towards.four_octet_asn {
|
||||||
|
let as4_path = attributes.iter().find_map(|attribute| match attribute {
|
||||||
|
PathAttribute::AsPath(path) => path.as4_path(),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
attributes.extend(as4_path);
|
||||||
|
}
|
||||||
|
// In ascending order of type code, as they were.
|
||||||
|
attributes.sort_by_key(PathAttribute::type_code);
|
||||||
|
update.path_attributes = attributes;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// What about a session decides how a route is announced on it.
|
/// What about a session decides how a route is announced on it.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct Towards {
|
pub struct Towards {
|
||||||
|
|||||||
+174
-5
@@ -1,4 +1,5 @@
|
|||||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||||
|
use std::path::Path;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -7,6 +8,7 @@ use bgp_fib::FibConfig;
|
|||||||
use bgp_packet::community;
|
use bgp_packet::community;
|
||||||
use bgp_packet::message::OriginPathAttribute;
|
use bgp_packet::message::OriginPathAttribute;
|
||||||
use bgp_packet::open::AddPathSendRecv;
|
use bgp_packet::open::AddPathSendRecv;
|
||||||
|
use bgp_policy::{Hook, Policy};
|
||||||
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
use bgp_primitives::constants::{AfiSafi, IpVersion};
|
||||||
use bgp_primitives::ip_prefix::IpPrefix;
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
use bgp_rib::{LocalRoute, LocalRouteAttrs};
|
||||||
@@ -52,6 +54,8 @@ pub struct Config {
|
|||||||
/// Where route origin validation gets its data; `None` for no
|
/// Where route origin validation gets its data; `None` for no
|
||||||
/// validation.
|
/// validation.
|
||||||
pub rpki: Option<RpkiConfig>,
|
pub rpki: Option<RpkiConfig>,
|
||||||
|
/// The policy script, if there is one.
|
||||||
|
pub policy: Option<Arc<Policy>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -76,6 +80,10 @@ pub struct PeerConfig {
|
|||||||
/// Refuse the peer if it does not say what it is itself (RFC 9234
|
/// Refuse the peer if it does not say what it is itself (RFC 9234
|
||||||
/// §4.2, "strict mode").
|
/// §4.2, "strict mode").
|
||||||
pub require_role: bool,
|
pub require_role: bool,
|
||||||
|
/// The operator's own rules for the routes the peer sends, and for
|
||||||
|
/// those it is sent: functions of the policy script.
|
||||||
|
pub import_hook: Option<Hook>,
|
||||||
|
pub export_hook: Option<Hook>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -84,7 +92,45 @@ impl Config {
|
|||||||
self.peers.iter().find(|peer| peer.endpoint.is_at(address))
|
self.peers.iter().find(|peer| peer.endpoint.is_at(address))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a configuration whose files, if it names any, are beside it
|
||||||
|
/// in the working directory.
|
||||||
pub fn from_proto(proto: BgpConfig) -> Result<Self, ConfigError> {
|
pub fn from_proto(proto: BgpConfig) -> Result<Self, ConfigError> {
|
||||||
|
Self::from_proto_in(proto, Path::new("."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a configuration that came from a file in `directory`: other
|
||||||
|
/// files it names by a relative path are looked for there.
|
||||||
|
pub fn from_proto_in(proto: BgpConfig, directory: &Path) -> Result<Self, ConfigError> {
|
||||||
|
let policy = proto
|
||||||
|
.policy
|
||||||
|
.as_ref()
|
||||||
|
.map(|policy| load_policy(policy, directory))
|
||||||
|
.transpose()?;
|
||||||
|
let default_hooks = proto.policy.as_ref().map_or(("", ""), |policy| {
|
||||||
|
(
|
||||||
|
policy.import_function.as_str(),
|
||||||
|
policy.export_function.as_str(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
// The function `named`, or the one for peers that name none.
|
||||||
|
let hook = |named: &str, default: &str| -> Result<Option<Hook>, PeerProblem> {
|
||||||
|
let name = if named.is_empty() { default } else { named };
|
||||||
|
match (&policy, name) {
|
||||||
|
(_, "") => Ok(None),
|
||||||
|
(Some(policy), name) => Ok(Some(policy.hook(name)?)),
|
||||||
|
(None, name) => Err(PeerProblem::NoPolicyScript(name.to_string())),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A function named for peers that name none has to be there
|
||||||
|
// whether or not there is such a peer.
|
||||||
|
if let Some(policy) = &policy {
|
||||||
|
for default in [default_hooks.0, default_hooks.1] {
|
||||||
|
if !default.is_empty() {
|
||||||
|
policy.hook(default)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Peers refuse an identifier of 0.0.0.0, as we do theirs.
|
// Peers refuse an identifier of 0.0.0.0, as we do theirs.
|
||||||
let router_id = Ipv4Addr::from_str(&proto.router_id)
|
let router_id = Ipv4Addr::from_str(&proto.router_id)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -118,7 +164,10 @@ impl Config {
|
|||||||
peer: name.clone(),
|
peer: name.clone(),
|
||||||
problem,
|
problem,
|
||||||
};
|
};
|
||||||
let peer = parse_peer(peer).map_err(rejected)?;
|
let functions = (peer.import.function.clone(), peer.export.function.clone());
|
||||||
|
let mut peer = parse_peer(peer).map_err(rejected)?;
|
||||||
|
peer.import_hook = hook(&functions.0, default_hooks.0).map_err(rejected)?;
|
||||||
|
peer.export_hook = hook(&functions.1, default_hooks.1).map_err(rejected)?;
|
||||||
// RFC 9234 is about what networks are to each other.
|
// RFC 9234 is about what networks are to each other.
|
||||||
if peer.role.is_some() && peer.remote_asn == proto.local_asn {
|
if peer.role.is_some() && peer.remote_asn == proto.local_asn {
|
||||||
return Err(rejected(PeerProblem::RoleInOwnAs));
|
return Err(rejected(PeerProblem::RoleInOwnAs));
|
||||||
@@ -194,6 +243,7 @@ impl Config {
|
|||||||
http_endpoint,
|
http_endpoint,
|
||||||
kernel: proto.kernel.as_ref().map(parse_kernel),
|
kernel: proto.kernel.as_ref().map(parse_kernel),
|
||||||
rpki,
|
rpki,
|
||||||
|
policy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,9 +349,29 @@ fn parse_peer(peer: Peer) -> Result<PeerConfig, PeerProblem> {
|
|||||||
import,
|
import,
|
||||||
role,
|
role,
|
||||||
require_role: peer.require_role,
|
require_role: peer.require_role,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads the policy script, from the configuration itself or from the
|
||||||
|
/// file it names.
|
||||||
|
fn load_policy(policy: &proto::PolicyScript, directory: &Path) -> Result<Arc<Policy>, ConfigError> {
|
||||||
|
let source = match (policy.path.as_str(), policy.source.as_str()) {
|
||||||
|
("", "") => return Err(ConfigError::PolicySource),
|
||||||
|
("", source) => source.to_string(),
|
||||||
|
(path, "") => {
|
||||||
|
let path = directory.join(path);
|
||||||
|
std::fs::read_to_string(&path).map_err(|e| ConfigError::PolicyFile {
|
||||||
|
path: path.display().to_string(),
|
||||||
|
error: e.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
(_, _) => return Err(ConfigError::PolicySource),
|
||||||
|
};
|
||||||
|
Ok(Policy::compile(&source)?)
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
|
fn parse_rpki(rpki: &proto::Rpki) -> Result<RpkiConfig, ConfigError> {
|
||||||
if rpki.caches.is_empty() {
|
if rpki.caches.is_empty() {
|
||||||
return Err(ConfigError::RpkiNoCaches);
|
return Err(ConfigError::RpkiNoCaches);
|
||||||
@@ -462,6 +532,94 @@ mod tests {
|
|||||||
parse(text).err().expect(text)
|
parse(text).err().expect(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a configuration whose files are in `testdata`.
|
||||||
|
fn parse_in_testdata(text: &str) -> Result<Config, ConfigError> {
|
||||||
|
let proto = protobuf::text_format::parse_from_str::<BgpConfig>(text)
|
||||||
|
.expect("the test's textproto is well formed");
|
||||||
|
let testdata = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../testdata");
|
||||||
|
Config::from_proto_in(proto, &testdata)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The operator's rules are a script: in a file beside the
|
||||||
|
/// configuration or in the configuration itself, with a function for
|
||||||
|
/// each direction of each peer, or one for all that name none.
|
||||||
|
#[test]
|
||||||
|
fn the_policy_script_is_read_and_its_functions_found() {
|
||||||
|
const SCRIPT: &str = "fn a(route, peer) { true } fn b(route, peer) { false }";
|
||||||
|
let with = |policy: &str, peers: &str| {
|
||||||
|
parse(&format!(
|
||||||
|
"{BASE}policy {{ source: \"{SCRIPT}\" {policy} }}\n\
|
||||||
|
peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 {peers} }}\n\
|
||||||
|
peers {{ name: \"r2\" endpoint {{ address: \"192.0.2.3\" }} remote_asn: 2 }}"
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let names = |config: &Config| -> Vec<(Option<String>, Option<String>)> {
|
||||||
|
let name = |hook: &Option<Hook>| hook.as_ref().map(|hook| hook.name().to_string());
|
||||||
|
config
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.map(|peer| (name(&peer.import_hook), name(&peer.export_hook)))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
// Nobody names a function, and there is none for those: none run.
|
||||||
|
let config = with("", "").unwrap();
|
||||||
|
assert_eq!(config.policy.as_ref().unwrap().functions(), ["a", "b"]);
|
||||||
|
assert_eq!(names(&config), [(None, None), (None, None)]);
|
||||||
|
// One peer names its own; the other gets the one for the rest.
|
||||||
|
let config = with(
|
||||||
|
"import_function: \"a\"",
|
||||||
|
"import { function: \"b\" } export { function: \"a\" }",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let (a, b) = (Some("a".to_string()), Some("b".to_string()));
|
||||||
|
assert_eq!(names(&config), [(b, a.clone()), (a, None)]);
|
||||||
|
|
||||||
|
// A function that is not there is found now, not when a route comes.
|
||||||
|
for (policy, peers) in [
|
||||||
|
("", "import { function: \"c\" }"),
|
||||||
|
("", "export { function: \"c\" }"),
|
||||||
|
("export_function: \"c\"", ""),
|
||||||
|
] {
|
||||||
|
let said = with(policy, peers).err().expect(peers).to_string();
|
||||||
|
assert!(
|
||||||
|
said.contains("the script has no function \"c\"; it has: a, b"),
|
||||||
|
"{said}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// So is a function named without a script, and a script that is
|
||||||
|
// not one.
|
||||||
|
let said = rejected(&format!(
|
||||||
|
"{BASE}peers {{ name: \"r1\" endpoint {{ address: \"192.0.2.2\" }} remote_asn: 1 \
|
||||||
|
import {{ function: \"a\" }} }}"
|
||||||
|
))
|
||||||
|
.to_string();
|
||||||
|
assert_eq!(
|
||||||
|
said,
|
||||||
|
"peer \"r1\": names the policy function \"a\", and there is no policy script"
|
||||||
|
);
|
||||||
|
let said = rejected(&format!("{BASE}policy {{ source: \"fn a(route {{\" }}")).to_string();
|
||||||
|
assert!(
|
||||||
|
said.starts_with("policy: the script does not parse"),
|
||||||
|
"{said}"
|
||||||
|
);
|
||||||
|
for neither_or_both in ["policy {}", "policy { path: \"a\" source: \"b\" }"] {
|
||||||
|
assert_eq!(
|
||||||
|
rejected(&format!("{BASE}{neither_or_both}")),
|
||||||
|
ConfigError::PolicySource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// From a file beside the configuration.
|
||||||
|
let config =
|
||||||
|
parse_in_testdata(&format!("{BASE}policy {{ path: \"policy.rhai\" }}")).unwrap();
|
||||||
|
assert!(!config.policy.unwrap().functions().is_empty());
|
||||||
|
let said = parse_in_testdata(&format!("{BASE}policy {{ path: \"nowhere.rhai\" }}"))
|
||||||
|
.err()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
assert!(said.starts_with("policy: cannot read "), "{said}");
|
||||||
|
}
|
||||||
|
|
||||||
/// The example that the documentation points to, and that operators
|
/// The example that the documentation points to, and that operators
|
||||||
/// start from: as it is, and with what it shows commented out put in.
|
/// start from: as it is, and with what it shows commented out put in.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -493,7 +651,18 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
assert_ne!(shown, example, "nothing was taken for configuration");
|
assert_ne!(shown, example, "nothing was taken for configuration");
|
||||||
let config = parse(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
|
let config = parse_in_testdata(&shown).unwrap_or_else(|e| panic!("{e}\n{shown}"));
|
||||||
|
// The script it names is the example script, and the functions it
|
||||||
|
// names are in it.
|
||||||
|
assert!(config.policy.is_some(), "{shown}");
|
||||||
|
assert_eq!(
|
||||||
|
config.peers[0].import_hook.as_ref().map(Hook::name),
|
||||||
|
Some("from_peer")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.peers[0].export_hook.as_ref().map(Hook::name),
|
||||||
|
Some("to_peer")
|
||||||
|
);
|
||||||
assert!(config.rpki.is_some(), "{shown}");
|
assert!(config.rpki.is_some(), "{shown}");
|
||||||
assert!(config.kernel.is_some(), "{shown}");
|
assert!(config.kernel.is_some(), "{shown}");
|
||||||
assert_eq!(config.peers[0].role, Some(PeerRole::Peer));
|
assert_eq!(config.peers[0].role, Some(PeerRole::Peer));
|
||||||
@@ -682,17 +851,17 @@ mod tests {
|
|||||||
other => panic!("{other}"),
|
other => panic!("{other}"),
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
problem(peer("r1", "192.0.2.2") + &peer("r1", "192.0.2.3")),
|
problem(peer("r1", "192.0.2.2") + peer("r1", "192.0.2.3").as_str()),
|
||||||
PeerProblem::DuplicateName
|
PeerProblem::DuplicateName
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
problem(peer("r1", "192.0.2.2") + &peer("r2", "192.0.2.2")),
|
problem(peer("r1", "192.0.2.2") + peer("r2", "192.0.2.2").as_str()),
|
||||||
PeerProblem::DuplicateAddress
|
PeerProblem::DuplicateAddress
|
||||||
);
|
);
|
||||||
|
|
||||||
// A link-local address is one peer on each interface it is on.
|
// A link-local address is one peer on each interface it is on.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
problem(peer("r1", "fe80::1%eth0") + &peer("r2", "fe80::1%eth0")),
|
problem(peer("r1", "fe80::1%eth0") + peer("r2", "fe80::1%eth0").as_str()),
|
||||||
PeerProblem::DuplicateAddress
|
PeerProblem::DuplicateAddress
|
||||||
);
|
);
|
||||||
let config = parse(&format!(
|
let config = parse(&format!(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ use bgp_packet::message::{
|
|||||||
};
|
};
|
||||||
use bgp_packet::open::AddPathSendRecv;
|
use bgp_packet::open::AddPathSendRecv;
|
||||||
use bgp_packet::update_decode::{UpdateError, merge_as4_path};
|
use bgp_packet::update_decode::{UpdateError, merge_as4_path};
|
||||||
|
use bgp_policy::Hook;
|
||||||
use bgp_primitives::ip_prefix::Nlri;
|
use bgp_primitives::ip_prefix::Nlri;
|
||||||
use bgp_primitives::parser::ParserContext;
|
use bgp_primitives::parser::ParserContext;
|
||||||
use bgp_rib::table::{Outcome, PeerIndex, PeerInfo, RibTable};
|
use bgp_rib::table::{Outcome, PeerIndex, PeerInfo, RibTable};
|
||||||
@@ -107,6 +108,10 @@ pub struct PeerSettings {
|
|||||||
pub role: Option<PeerRole>,
|
pub role: Option<PeerRole>,
|
||||||
/// Refuse the peer if it does not say what it is (RFC 9234).
|
/// Refuse the peer if it does not say what it is (RFC 9234).
|
||||||
pub require_role: bool,
|
pub require_role: bool,
|
||||||
|
/// The functions of the policy script for the peer's routes and for
|
||||||
|
/// the routes it is sent.
|
||||||
|
pub import_hook: Option<Hook>,
|
||||||
|
pub export_hook: Option<Hook>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PeerSettings {
|
impl PeerSettings {
|
||||||
@@ -123,6 +128,8 @@ impl PeerSettings {
|
|||||||
import: Arc::new(ImportPolicy::permissive()),
|
import: Arc::new(ImportPolicy::permissive()),
|
||||||
role: None,
|
role: None,
|
||||||
require_role: false,
|
require_role: false,
|
||||||
|
import_hook: None,
|
||||||
|
export_hook: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +145,8 @@ impl From<&PeerConfig> for PeerSettings {
|
|||||||
import: Arc::new(peer.import.clone()),
|
import: Arc::new(peer.import.clone()),
|
||||||
role: peer.role,
|
role: peer.role,
|
||||||
require_role: peer.require_role,
|
require_role: peer.require_role,
|
||||||
|
import_hook: peer.import_hook.clone(),
|
||||||
|
export_hook: peer.export_hook.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,6 +242,9 @@ pub async fn run_session(
|
|||||||
|
|
||||||
/// What exists only once a session is Established.
|
/// What exists only once a session is Established.
|
||||||
struct Established {
|
struct Established {
|
||||||
|
/// The policy function for the peer's routes, and the peer as it is
|
||||||
|
/// shown to it.
|
||||||
|
import_script: Option<(Hook, bgp_policy::Peer)>,
|
||||||
/// When it was.
|
/// When it was.
|
||||||
since: Instant,
|
since: Instant,
|
||||||
peer_id: PeerId,
|
peer_id: PeerId,
|
||||||
@@ -278,6 +290,10 @@ impl Established {
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.map_or(Checks::NONE, |validation| validation.validator.checks()),
|
.map_or(Checks::NONE, |validation| validation.validator.checks()),
|
||||||
refusals,
|
refusals,
|
||||||
|
script: self
|
||||||
|
.import_script
|
||||||
|
.as_ref()
|
||||||
|
.map(|(hook, peer)| routes::Script { hook, peer }),
|
||||||
};
|
};
|
||||||
(import, &mut self.held)
|
(import, &mut self.held)
|
||||||
}
|
}
|
||||||
@@ -446,6 +462,8 @@ struct Session {
|
|||||||
peer_name: Option<String>,
|
peer_name: Option<String>,
|
||||||
/// Which of the peer's routes are taken.
|
/// Which of the peer's routes are taken.
|
||||||
import: Arc<ImportPolicy>,
|
import: Arc<ImportPolicy>,
|
||||||
|
/// The policy script's functions for routes from and to the peer.
|
||||||
|
hooks: (Option<Hook>, Option<Hook>),
|
||||||
/// What the peer is to us, if that is known.
|
/// What the peer is to us, if that is known.
|
||||||
role: Option<PeerRole>,
|
role: Option<PeerRole>,
|
||||||
/// Where what is refused is noted.
|
/// Where what is refused is noted.
|
||||||
@@ -502,6 +520,7 @@ impl Session {
|
|||||||
server,
|
server,
|
||||||
peer_name: peer.name,
|
peer_name: peer.name,
|
||||||
import: peer.import,
|
import: peer.import,
|
||||||
|
hooks: (peer.import_hook, peer.export_hook),
|
||||||
role: peer.role,
|
role: peer.role,
|
||||||
said: Said::new(),
|
said: Said::new(),
|
||||||
};
|
};
|
||||||
@@ -806,7 +825,23 @@ impl Session {
|
|||||||
changes: handle.validate(subject),
|
changes: handle.validate(subject),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// The peer as the policy script sees it.
|
||||||
|
let script_peer = || {
|
||||||
|
bgp_policy::Peer::new(
|
||||||
|
self.refusals.label(),
|
||||||
|
self.peer_addr.ip(),
|
||||||
|
negotiated.peer_asn,
|
||||||
|
self.server.local_asn,
|
||||||
|
self.role.map_or("", PeerRole::in_scripts),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
handle.export = self
|
||||||
|
.hooks
|
||||||
|
.1
|
||||||
|
.clone()
|
||||||
|
.map(|hook| Arc::new((hook, script_peer())));
|
||||||
self.established = Some(Established {
|
self.established = Some(Established {
|
||||||
|
import_script: self.hooks.0.clone().map(|hook| (hook, script_peer())),
|
||||||
since: Instant::now(),
|
since: Instant::now(),
|
||||||
validation,
|
validation,
|
||||||
held: Held::default(),
|
held: Held::default(),
|
||||||
@@ -1299,6 +1334,7 @@ mod tests {
|
|||||||
std::net::IpAddr::from(Ipv4Addr::new(192, 0, 2, 1)).into(),
|
std::net::IpAddr::from(Ipv4Addr::new(192, 0, 2, 1)).into(),
|
||||||
"test",
|
"test",
|
||||||
),
|
),
|
||||||
|
script: None,
|
||||||
};
|
};
|
||||||
routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap();
|
routes::apply(&table, &import, &mut Held::default(), &update, None).unwrap();
|
||||||
assert_eq!(table.len(), 1);
|
assert_eq!(table.len(), 1);
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ pub enum ConfigError {
|
|||||||
HttpPort,
|
HttpPort,
|
||||||
#[error("rpki: no caches are listed")]
|
#[error("rpki: no caches are listed")]
|
||||||
RpkiNoCaches,
|
RpkiNoCaches,
|
||||||
|
#[error("policy: give the script's path or its source, and not both")]
|
||||||
|
PolicySource,
|
||||||
|
#[error("policy: cannot read {path}: {error}")]
|
||||||
|
PolicyFile { path: String, error: String },
|
||||||
|
#[error("policy: {0}")]
|
||||||
|
Policy(#[from] bgp_policy::PolicyError),
|
||||||
#[error("rpki: the port of cache {0:?} is required")]
|
#[error("rpki: the port of cache {0:?} is required")]
|
||||||
RpkiCachePort(String),
|
RpkiCachePort(String),
|
||||||
#[error("peer {peer:?}: {problem}")]
|
#[error("peer {peer:?}: {problem}")]
|
||||||
@@ -192,6 +198,10 @@ pub enum ConfigError {
|
|||||||
pub enum PeerProblem {
|
pub enum PeerProblem {
|
||||||
#[error("endpoint is missing")]
|
#[error("endpoint is missing")]
|
||||||
MissingEndpoint,
|
MissingEndpoint,
|
||||||
|
#[error("{0}")]
|
||||||
|
Policy(#[from] bgp_policy::PolicyError),
|
||||||
|
#[error("names the policy function {0:?}, and there is no policy script")]
|
||||||
|
NoPolicyScript(String),
|
||||||
#[error("endpoint is invalid: {0}")]
|
#[error("endpoint is invalid: {0}")]
|
||||||
Endpoint(Box<ConfigError>),
|
Endpoint(Box<ConfigError>),
|
||||||
#[error("remote_asn must be non-zero")]
|
#[error("remote_asn must be non-zero")]
|
||||||
|
|||||||
@@ -164,6 +164,24 @@ pub static RIB_JOURNAL_ENTRIES: LazyLock<IntGauge> = LazyLock::new(|| {
|
|||||||
.expect("metric registration")
|
.expect("metric registration")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pub static POLICY_RUNS: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||||
|
register_int_counter_vec!(
|
||||||
|
"bgp_policy_runs_total",
|
||||||
|
"Times a function of the policy script was asked about a route, by direction",
|
||||||
|
&["direction"]
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub static POLICY_FAILURES: LazyLock<IntCounterVec> = LazyLock::new(|| {
|
||||||
|
register_int_counter_vec!(
|
||||||
|
"bgp_policy_failures_total",
|
||||||
|
"Times a function of the policy script failed, which refuses the route, by function",
|
||||||
|
&["function"]
|
||||||
|
)
|
||||||
|
.expect("metric registration")
|
||||||
|
});
|
||||||
|
|
||||||
/// Whether each configured peer's session is up: what to alert on when a
|
/// Whether each configured peer's session is up: what to alert on when a
|
||||||
/// peer goes away, which a count of sessions cannot say.
|
/// peer goes away, which a count of sessions cannot say.
|
||||||
pub static PEER_UP: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
pub static PEER_UP: LazyLock<IntGaugeVec> = LazyLock::new(|| {
|
||||||
@@ -207,6 +225,8 @@ pub fn init() {
|
|||||||
LazyLock::force(&RIB_PREFIXES);
|
LazyLock::force(&RIB_PREFIXES);
|
||||||
LazyLock::force(&RIB_ATTRIBUTE_SETS);
|
LazyLock::force(&RIB_ATTRIBUTE_SETS);
|
||||||
LazyLock::force(&RIB_JOURNAL_ENTRIES);
|
LazyLock::force(&RIB_JOURNAL_ENTRIES);
|
||||||
|
LazyLock::force(&POLICY_RUNS);
|
||||||
|
LazyLock::force(&POLICY_FAILURES);
|
||||||
LazyLock::force(&PEER_UP);
|
LazyLock::force(&PEER_UP);
|
||||||
BUILD_INFO
|
BUILD_INFO
|
||||||
.with_label_values(&[env!("CARGO_PKG_VERSION")])
|
.with_label_values(&[env!("CARGO_PKG_VERSION")])
|
||||||
|
|||||||
@@ -188,6 +188,11 @@ pub enum Rejection {
|
|||||||
/// The next hop cannot be reached from this machine as its interfaces
|
/// The next hop cannot be reached from this machine as its interfaces
|
||||||
/// are now, so the kernel could not be given the route.
|
/// are now, so the kernel could not be given the route.
|
||||||
NextHopUnreachable,
|
NextHopUnreachable,
|
||||||
|
/// The operator's policy function for the peer refused the route.
|
||||||
|
Policy,
|
||||||
|
/// The operator's policy function failed on the route, which is as
|
||||||
|
/// good as refusing it.
|
||||||
|
PolicyFailed,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Rejection {
|
impl Rejection {
|
||||||
@@ -201,7 +206,7 @@ impl Rejection {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ALL: [Self; 17] = [
|
pub const ALL: [Self; 19] = [
|
||||||
Self::BogonPrefix,
|
Self::BogonPrefix,
|
||||||
Self::TooShort,
|
Self::TooShort,
|
||||||
Self::TooSpecific,
|
Self::TooSpecific,
|
||||||
@@ -219,6 +224,8 @@ impl Rejection {
|
|||||||
Self::NextHopNotPeer,
|
Self::NextHopNotPeer,
|
||||||
Self::OwnPrefix,
|
Self::OwnPrefix,
|
||||||
Self::NextHopUnreachable,
|
Self::NextHopUnreachable,
|
||||||
|
Self::Policy,
|
||||||
|
Self::PolicyFailed,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Where the reason is in [`Rejection::ALL`].
|
/// Where the reason is in [`Rejection::ALL`].
|
||||||
@@ -245,6 +252,8 @@ impl Rejection {
|
|||||||
Self::NextHopNotPeer => "next hop is not the peer",
|
Self::NextHopNotPeer => "next hop is not the peer",
|
||||||
Self::OwnPrefix => "prefix is our own",
|
Self::OwnPrefix => "prefix is our own",
|
||||||
Self::NextHopUnreachable => "next hop unreachable",
|
Self::NextHopUnreachable => "next hop unreachable",
|
||||||
|
Self::Policy => "refused by policy function",
|
||||||
|
Self::PolicyFailed => "policy function failed",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,6 +338,17 @@ impl PeerRole {
|
|||||||
Self::RouteServerClient => "route server client",
|
Self::RouteServerClient => "route server client",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The role as a policy script has it in `peer.role`: one word.
|
||||||
|
pub fn in_scripts(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Provider => "provider",
|
||||||
|
Self::Customer => "customer",
|
||||||
|
Self::Peer => "peer",
|
||||||
|
Self::RouteServer => "route_server",
|
||||||
|
Self::RouteServerClient => "route_server_client",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whose routes are being judged.
|
/// Whose routes are being judged.
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ mod tests {
|
|||||||
..Checks::NONE
|
..Checks::NONE
|
||||||
},
|
},
|
||||||
refusals: &self.refusals.of_peer(address.into(), "test"),
|
refusals: &self.refusals.of_peer(address.into(), "test"),
|
||||||
|
script: None,
|
||||||
};
|
};
|
||||||
work(&self.table, &import, &mut peer.held)
|
work(&self.table, &import, &mut peer.held)
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-4
@@ -4,18 +4,24 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::cell::OnceCell;
|
use std::cell::OnceCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage, only_to_customer_wire};
|
use bgp_packet::message::{NlriNextHop, PathAttribute, UpdateMessage, only_to_customer_wire};
|
||||||
|
use bgp_policy::{Direction, Hook, PolicyError, Subject};
|
||||||
use bgp_primitives::constants::IpVersion;
|
use bgp_primitives::constants::IpVersion;
|
||||||
use bgp_primitives::ip_prefix::Nlri;
|
use bgp_primitives::ip_prefix::Nlri;
|
||||||
use bgp_primitives::parser::EncodeError;
|
use bgp_primitives::parser::EncodeError;
|
||||||
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
|
use bgp_rib::table::{AttrSet, Outcome, PeerIndex, RibTable};
|
||||||
use bgp_rpki::{PathValidity, Validity};
|
use bgp_rpki::{PathValidity, Validity};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::metrics::{POLICY_FAILURES, POLICY_RUNS};
|
||||||
use crate::policy::{self, ImportPolicy, Rejection, RouteSource};
|
use crate::policy::{self, ImportPolicy, Rejection, RouteSource};
|
||||||
use crate::refusals::PeerRefusals;
|
use crate::refusals::PeerRefusals;
|
||||||
use crate::rpki::Checks;
|
use crate::rpki::Checks;
|
||||||
|
use crate::sync::IgnorePoison;
|
||||||
|
use crate::throttle::Throttle;
|
||||||
|
|
||||||
/// The most routes of one peer that are held aside as invalid. Far more
|
/// The most routes of one peer that are held aside as invalid. Far more
|
||||||
/// than a peer sending the whole Internet has; a bound all the same.
|
/// than a peer sending the whole Internet has; a bound all the same.
|
||||||
@@ -41,6 +47,30 @@ pub(crate) struct Import<'a> {
|
|||||||
pub(crate) checks: Checks<'a>,
|
pub(crate) checks: Checks<'a>,
|
||||||
/// Where what is refused is noted.
|
/// Where what is refused is noted.
|
||||||
pub(crate) refusals: &'a PeerRefusals,
|
pub(crate) refusals: &'a PeerRefusals,
|
||||||
|
/// The operator's own rules for the peer's routes, if there are any.
|
||||||
|
pub(crate) script: Option<Script<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A function of the policy script, and the peer as it is shown to it.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub(crate) struct Script<'a> {
|
||||||
|
pub(crate) hook: &'a Hook,
|
||||||
|
pub(crate) peer: &'a bgp_policy::Peer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Says that a policy function failed. Counted every time, said once in a
|
||||||
|
/// while: a function that fails on one route fails on the next.
|
||||||
|
pub(crate) fn policy_failed(function: &str, peer: &str, error: &PolicyError) {
|
||||||
|
static SAID: Mutex<Throttle> = Mutex::new(Throttle::every(Duration::from_secs(60)));
|
||||||
|
POLICY_FAILURES.with_label_values(&[function]).inc();
|
||||||
|
if let Some(unsaid) = SAID.locked().pass() {
|
||||||
|
warn!(
|
||||||
|
peer,
|
||||||
|
err = %error,
|
||||||
|
others_since_last_report = unsaid,
|
||||||
|
"A policy function failed; the route it was asked about is refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A peer's routes that are not in the routing table because the RPKI's
|
/// A peer's routes that are not in the routing table because the RPKI's
|
||||||
@@ -162,6 +192,10 @@ fn only_to_customer_mark(import: &Import<'_>, update: &UpdateMessage) -> Option<
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Routes a policy function took, with their attributes as the function
|
||||||
|
/// left them if it changed any.
|
||||||
|
type Taken<'n> = (Cow<'n, [Nlri]>, Option<Vec<PathAttribute>>);
|
||||||
|
|
||||||
/// One UPDATE on its way into the table.
|
/// One UPDATE on its way into the table.
|
||||||
struct Applying<'a> {
|
struct Applying<'a> {
|
||||||
table: &'a RibTable,
|
table: &'a RibTable,
|
||||||
@@ -230,6 +264,99 @@ impl Applying<'_> {
|
|||||||
.checks
|
.checks
|
||||||
.next_hops
|
.next_hops
|
||||||
.is_none_or(|next_hops| next_hops.next_hop_is_near(family, &used_next_hop));
|
.is_none_or(|next_hops| next_hops.next_hop_is_near(family, &used_next_hop));
|
||||||
|
// The operator's own rules come after the general ones, and before
|
||||||
|
// what may yet change: a route they refuse is refused for good.
|
||||||
|
for (routes, changed) in self.scripted(nlri, next_hop) {
|
||||||
|
self.admit(&routes, next_hop, origin, near, changed.as_deref())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the peer's policy function, if it has one, about the routes of
|
||||||
|
/// `nlri`. Returns those it takes, with their attributes as the
|
||||||
|
/// function left them if it changed any; the others are refused here.
|
||||||
|
///
|
||||||
|
/// The function is asked once if it does not ask for the prefix: its
|
||||||
|
/// answer is then the same for all of them. Otherwise it is asked
|
||||||
|
/// about each, and routes it treats alike are returned together.
|
||||||
|
fn scripted<'n>(&mut self, nlri: &'n [Nlri], next_hop: Option<&NlriNextHop>) -> Vec<Taken<'n>> {
|
||||||
|
let Some(script) = self.import.script else {
|
||||||
|
return vec![(Cow::Borrowed(nlri), None)];
|
||||||
|
};
|
||||||
|
// Without the attributes that hold prefixes, which are not the
|
||||||
|
// route's and can be long.
|
||||||
|
let names_prefixes = |attribute: &PathAttribute| {
|
||||||
|
matches!(
|
||||||
|
attribute,
|
||||||
|
PathAttribute::MpReachNlri(_) | PathAttribute::MpUnreachNlri(_)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let attributes: Cow<'_, [PathAttribute]> =
|
||||||
|
if self.update.path_attributes.iter().any(names_prefixes) {
|
||||||
|
let mut plain = self.update.path_attributes.clone();
|
||||||
|
plain.retain(|attribute| !names_prefixes(attribute));
|
||||||
|
Cow::Owned(plain)
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(&self.update.path_attributes)
|
||||||
|
};
|
||||||
|
let mut taken: Vec<Taken<'n>> = Vec::new();
|
||||||
|
for (at, route) in nlri.iter().enumerate() {
|
||||||
|
POLICY_RUNS.with_label_values(&["import"]).inc();
|
||||||
|
let verdict = script.hook.run(
|
||||||
|
script.peer,
|
||||||
|
&Subject {
|
||||||
|
prefix: &route.prefix,
|
||||||
|
attributes: &attributes,
|
||||||
|
next_hop: next_hop.copied(),
|
||||||
|
direction: Direction::Import,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let verdict = match verdict {
|
||||||
|
Ok(verdict) => verdict,
|
||||||
|
Err(e) => {
|
||||||
|
policy_failed(script.hook.name(), self.import.refusals.label(), &e);
|
||||||
|
self.refuse(std::slice::from_ref(route), Rejection::PolicyFailed);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// The first answer is everyone's, unless it was about the
|
||||||
|
// prefix.
|
||||||
|
let (routes, all) = if at == 0 && !verdict.saw_prefix {
|
||||||
|
(nlri, true)
|
||||||
|
} else {
|
||||||
|
(std::slice::from_ref(route), false)
|
||||||
|
};
|
||||||
|
if !verdict.accept {
|
||||||
|
self.refuse(routes, Rejection::Policy);
|
||||||
|
} else if all {
|
||||||
|
taken.push((Cow::Borrowed(routes), verdict.attributes));
|
||||||
|
} else {
|
||||||
|
match taken
|
||||||
|
.iter_mut()
|
||||||
|
.find(|(_, changed)| *changed == verdict.attributes)
|
||||||
|
{
|
||||||
|
Some((alike, _)) => alike.to_mut().push(*route),
|
||||||
|
None => taken.push((Cow::Owned(vec![*route]), verdict.attributes)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taken
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts into the table those of `nlri` that pass what is asked of
|
||||||
|
/// each route by itself. `changed` is their attributes, if the policy
|
||||||
|
/// function changed them.
|
||||||
|
fn admit(
|
||||||
|
&mut self,
|
||||||
|
nlri: &[Nlri],
|
||||||
|
next_hop: Option<&NlriNextHop>,
|
||||||
|
origin: Option<u32>,
|
||||||
|
near: bool,
|
||||||
|
changed: Option<&[PathAttribute]>,
|
||||||
|
) -> Result<(), EncodeError> {
|
||||||
// Nearly always every route is taken, and then nothing is copied.
|
// Nearly always every route is taken, and then nothing is copied.
|
||||||
let mut accepted = Cow::Borrowed(nlri);
|
let mut accepted = Cow::Borrowed(nlri);
|
||||||
let mut invalid = Vec::new();
|
let mut invalid = Vec::new();
|
||||||
@@ -254,7 +381,7 @@ impl Applying<'_> {
|
|||||||
if accepted.is_empty() && invalid.is_empty() {
|
if accepted.is_empty() && invalid.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let attrs = self.attributes(next_hop)?;
|
let attrs = self.attributes(next_hop, changed)?;
|
||||||
for route in invalid {
|
for route in invalid {
|
||||||
self.held.hold(route, &attrs);
|
self.held.hold(route, &attrs);
|
||||||
}
|
}
|
||||||
@@ -332,9 +459,23 @@ impl Applying<'_> {
|
|||||||
self.applied.outcome += self.table.withdraw(self.import.peer, refused);
|
self.applied.outcome += self.table.withdraw(self.import.peer, refused);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The attribute set the UPDATE's routes go into the table with.
|
/// The attribute set the UPDATE's routes go into the table with:
|
||||||
fn attributes(&self, next_hop: Option<&NlriNextHop>) -> Result<Arc<AttrSet>, EncodeError> {
|
/// `changed`, if the policy function changed the attributes, or else
|
||||||
|
/// those the UPDATE came with.
|
||||||
|
fn attributes(
|
||||||
|
&self,
|
||||||
|
next_hop: Option<&NlriNextHop>,
|
||||||
|
changed: Option<&[PathAttribute]>,
|
||||||
|
) -> Result<Arc<AttrSet>, EncodeError> {
|
||||||
let store = self.table.attrs();
|
let store = self.table.attrs();
|
||||||
|
if let Some(changed) = changed {
|
||||||
|
let Some(marked_by) = self.mark else {
|
||||||
|
return store.intern(changed, next_hop);
|
||||||
|
};
|
||||||
|
let mut marked = changed.to_vec();
|
||||||
|
marked.push(PathAttribute::OnlyToCustomer(marked_by));
|
||||||
|
return store.intern(&marked, next_hop);
|
||||||
|
}
|
||||||
let Some(marked_by) = self.mark else {
|
let Some(marked_by) = self.mark else {
|
||||||
if let Some(set) = self
|
if let Some(set) = self
|
||||||
.attributes_wire
|
.attributes_wire
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ impl BgpServer {
|
|||||||
endpoint: p.endpoint.clone(),
|
endpoint: p.endpoint.clone(),
|
||||||
remote_asn: p.remote_asn,
|
remote_asn: p.remote_asn,
|
||||||
role: p.role,
|
role: p.role,
|
||||||
|
import_function: p.import_hook.as_ref().map(|hook| hook.name().into()),
|
||||||
|
export_function: p.export_hook.as_ref().map(|hook| hook.name().into()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// The client starts now: with luck the data is there before the
|
// The client starts now: with luck the data is there before the
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ pub struct ConfiguredPeer {
|
|||||||
pub remote_asn: u32,
|
pub remote_asn: u32,
|
||||||
/// What the peer is to us, if that was said.
|
/// What the peer is to us, if that was said.
|
||||||
pub role: Option<PeerRole>,
|
pub role: Option<PeerRole>,
|
||||||
|
/// The functions of the policy script for routes from and to the
|
||||||
|
/// peer, by name.
|
||||||
|
pub import_function: Option<String>,
|
||||||
|
pub export_function: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Counts kept by a session as it goes, for whoever wants to look.
|
/// Counts kept by a session as it goes, for whoever wants to look.
|
||||||
@@ -145,6 +149,9 @@ pub struct SessionHandle {
|
|||||||
pub rib_peer: PeerIndex,
|
pub rib_peer: PeerIndex,
|
||||||
/// How many routes the peer has, and how many were refused.
|
/// How many routes the peer has, and how many were refused.
|
||||||
pub counters: Arc<SessionCounters>,
|
pub counters: Arc<SessionCounters>,
|
||||||
|
/// The policy function for routes on their way to the peer, with the
|
||||||
|
/// peer as it is shown to it.
|
||||||
|
pub export: Option<Arc<(bgp_policy::Hook, bgp_policy::Peer)>>,
|
||||||
outbound: mpsc::Sender<Message>,
|
outbound: mpsc::Sender<Message>,
|
||||||
/// Which of the RPKI's checks the peer's routes are subject to, if
|
/// Which of the RPKI's checks the peer's routes are subject to, if
|
||||||
/// any, and where the session is told to look at its routes again.
|
/// any, and where the session is told to look at its routes again.
|
||||||
@@ -181,6 +188,7 @@ impl SessionHandle {
|
|||||||
add_path_send: negotiated.add_path_send.clone(),
|
add_path_send: negotiated.add_path_send.clone(),
|
||||||
rib_peer,
|
rib_peer,
|
||||||
counters: Arc::default(),
|
counters: Arc::default(),
|
||||||
|
export: None,
|
||||||
outbound,
|
outbound,
|
||||||
validation: None,
|
validation: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ pub struct PeerView {
|
|||||||
/// Whether the peer's OPEN said the same (RFC 9234). `false` for a
|
/// Whether the peer's OPEN said the same (RFC 9234). `false` for a
|
||||||
/// peer that does not say; one that says otherwise gets no session.
|
/// peer that does not say; one that says otherwise gets no session.
|
||||||
pub role_confirmed: Option<bool>,
|
pub role_confirmed: Option<bool>,
|
||||||
|
/// The functions of the policy script that the peer's routes, and
|
||||||
|
/// the routes it is sent, are given to; absent where there is none.
|
||||||
|
pub import_function: Option<String>,
|
||||||
|
pub export_function: Option<String>,
|
||||||
/// Why the last connection attempt or session ended, while in Backoff.
|
/// Why the last connection attempt or session ended, while in Backoff.
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
pub retry_at_unix: Option<u64>,
|
pub retry_at_unix: Option<u64>,
|
||||||
@@ -115,6 +119,8 @@ impl From<&SessionHandle> for PeerView {
|
|||||||
held_routes: Some(s.counters.held()),
|
held_routes: Some(s.counters.held()),
|
||||||
role: s.role.map(PeerRole::as_str),
|
role: s.role.map(PeerRole::as_str),
|
||||||
role_confirmed: s.role.map(|_| s.role_confirmed),
|
role_confirmed: s.role.map(|_| s.role_confirmed),
|
||||||
|
import_function: None,
|
||||||
|
export_function: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
retry_at_unix: None,
|
retry_at_unix: None,
|
||||||
}
|
}
|
||||||
@@ -149,6 +155,8 @@ impl PeerView {
|
|||||||
held_routes: None,
|
held_routes: None,
|
||||||
role: peer.role.map(PeerRole::as_str),
|
role: peer.role.map(PeerRole::as_str),
|
||||||
role_confirmed: None,
|
role_confirmed: None,
|
||||||
|
import_function: peer.import_function.clone(),
|
||||||
|
export_function: peer.export_function.clone(),
|
||||||
last_error,
|
last_error,
|
||||||
retry_at_unix,
|
retry_at_unix,
|
||||||
}
|
}
|
||||||
@@ -172,6 +180,8 @@ pub fn peer_views(registry: &SessionRegistry) -> Vec<PeerView> {
|
|||||||
claimed.insert(session.peer);
|
claimed.insert(session.peer);
|
||||||
PeerView {
|
PeerView {
|
||||||
name: Some(peer.name.clone()),
|
name: Some(peer.name.clone()),
|
||||||
|
import_function: peer.import_function.clone(),
|
||||||
|
export_function: peer.export_function.clone(),
|
||||||
..PeerView::from(session)
|
..PeerView::from(session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
|||||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null, kernel: null };
|
const state = { status: null, peers: [], local: [], received: [], receivedPage: {}, refused: [], rpki: null, kernel: null };
|
||||||
const DASH = '<span class="dim">-</span>';
|
const DASH = '<span class="dim">-</span>';
|
||||||
|
// The functions of the policy script a peer's routes go through.
|
||||||
|
const policy = p => (p.import_function || p.export_function)
|
||||||
|
? ` <span class="dim" title="Functions of the policy script: for routes from the peer, and for routes to it">${[p.import_function && 'in: ' + esc(p.import_function), p.export_function && 'out: ' + esc(p.export_function)].filter(Boolean).join(', ')}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
const fmtDur = s => {
|
const fmtDur = s => {
|
||||||
@@ -182,13 +186,13 @@ from <select id="lookup-peer"><option value="">any peer</option></select>
|
|||||||
const up = p.state === 'Established';
|
const up = p.state === 'Established';
|
||||||
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
const detail = p.state === 'Backoff' ? `${esc(p.last_error || '')}; retry in ${until(p.retry_at_unix)}` : '';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' ✓' : ''}</span>` : ''}</td>
|
<td>${p.name ? esc(p.name) : '<span class="dim">(inbound)</span>'}${policy(p)}</td><td class="mono">${esc(p.peer_addr)}</td><td>${p.peer_asn}${p.role ? ` <span class="dim" title="${p.role_confirmed ? 'The peer says so too (RFC 9234)' : 'By our configuration; the peer has not said what it is'}">${esc(p.role)}${p.role_confirmed ? ' ✓' : ''}</span>` : ''}</td>
|
||||||
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
<td><span class="${STATE_CLASS[p.state] || ''}">${esc(p.state)}</span></td><td>${up ? (p.direction === 'outbound' ? 'us' : 'peer') : DASH}</td>
|
||||||
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
|
<td class="mono">${up ? esc(p.local_addr) : DASH}</td><td>${up ? fmtTime(p.established_at_unix) : DASH}</td>
|
||||||
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
|
<td>${up ? age(p.established_at_unix) : DASH}</td><td>${up ? (p.four_octet_asn ? 'yes' : 'no') : DASH}</td>
|
||||||
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) + (p.extended_next_hop ? ' · IPv4 with IPv6 next hops' : '') : DASH}</td>
|
<td>${up ? esc((p.address_families || []).join(', ')) + addPath(p) + (p.extended_next_hop ? ' · IPv4 with IPv6 next hops' : '') : DASH}</td>
|
||||||
<td>${up ? p.routes : DASH}</td>
|
<td>${up ? p.routes : DASH}</td>
|
||||||
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
<td>${up ? (p.rejected_routes ? `<a class="bad" href="#refused">${p.rejected_routes}</a>` : 0) + (p.held_routes ? ` <span class="dim" title="Refused as RPKI or ASPA invalid, or for a next hop that cannot be reached, and kept aside, to be taken if that changes">(${p.held_routes} held)</span>` : '') : DASH}</td><td>${detail}</td></tr>`;
|
||||||
}).join(''));
|
}).join(''));
|
||||||
renderPeerChoices();
|
renderPeerChoices();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ cargo-fuzz = true
|
|||||||
bgp-packet = { path = "../crates/packet" }
|
bgp-packet = { path = "../crates/packet" }
|
||||||
bgp-primitives = { path = "../crates/primitives" }
|
bgp-primitives = { path = "../crates/primitives" }
|
||||||
bgp-fib = { path = "../crates/fib" }
|
bgp-fib = { path = "../crates/fib" }
|
||||||
|
bgp-policy = { path = "../crates/policy" }
|
||||||
bgp-rib = { path = "../crates/rib" }
|
bgp-rib = { path = "../crates/rib" }
|
||||||
bgp-rpki = { path = "../crates/rpki" }
|
bgp-rpki = { path = "../crates/rpki" }
|
||||||
bgp-server = { path = "../crates/server" }
|
bgp-server = { path = "../crates/server" }
|
||||||
@@ -35,6 +36,13 @@ test = false
|
|||||||
doc = false
|
doc = false
|
||||||
bench = false
|
bench = false
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "policy"
|
||||||
|
path = "fuzz_targets/policy.rs"
|
||||||
|
test = false
|
||||||
|
doc = false
|
||||||
|
bench = false
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "message"
|
name = "message"
|
||||||
path = "fuzz_targets/message.rs"
|
path = "fuzz_targets/message.rs"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ you leave them running.
|
|||||||
| `rtr` | A stream of RTR PDUs, as an RPKI cache sends them |
|
| `rtr` | A stream of RTR PDUs, as an RPKI cache sends them |
|
||||||
| `netlink` | Messages from the kernel's routing socket |
|
| `netlink` | Messages from the kernel's routing socket |
|
||||||
| `attrs` | The routing table's reading of stored attribute bytes |
|
| `attrs` | The routing table's reading of stored attribute bytes |
|
||||||
|
| `policy` | The example policy script on routes with any attributes |
|
||||||
|
|
||||||
For the two message targets a finding is either a panic, or a message the
|
For the two message targets a finding is either a panic, or a message the
|
||||||
decoder accepted that cannot be written and read back as itself.
|
decoder accepted that cannot be written and read back as itself.
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! The operator's policy script, asked about routes with whatever
|
||||||
|
//! attributes a peer can send. The example script (testdata/policy.rhai)
|
||||||
|
//! touches most of what a script can; whatever the route, a function
|
||||||
|
//! either answers or fails, and never panics. What it changes must still
|
||||||
|
//! be attributes that can be written.
|
||||||
|
#![no_main]
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use bgp_packet::message::{Message, PathAttribute, UpdateMessage};
|
||||||
|
use bgp_policy::{Direction, Hook, Peer, Policy, Subject};
|
||||||
|
use bgp_primitives::ip_prefix::IpPrefix;
|
||||||
|
use bgp_primitives::parser::ParserContext;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use libfuzzer_sys::fuzz_target;
|
||||||
|
|
||||||
|
static HOOKS: LazyLock<(Arc<Policy>, Vec<Hook>)> = LazyLock::new(|| {
|
||||||
|
let policy = Policy::compile(include_str!("../../testdata/policy.rhai")).unwrap();
|
||||||
|
let hooks = ["from_peer", "to_peer"]
|
||||||
|
.into_iter()
|
||||||
|
.map(|name| policy.hook(name).unwrap())
|
||||||
|
.collect();
|
||||||
|
(policy, hooks)
|
||||||
|
});
|
||||||
|
|
||||||
|
fuzz_target!(|data: &[u8]| {
|
||||||
|
let Some((selector, attributes)) = data.split_first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let ctx = ParserContext {
|
||||||
|
four_octet_asn: true,
|
||||||
|
ibgp: false,
|
||||||
|
add_path: Vec::new(),
|
||||||
|
};
|
||||||
|
// An UPDATE with no routes withdrawn, these attributes, and one route.
|
||||||
|
let mut body = vec![0, 0];
|
||||||
|
let attributes = &attributes[..attributes.len().min(4000)];
|
||||||
|
body.extend_from_slice(&(attributes.len() as u16).to_be_bytes());
|
||||||
|
body.extend_from_slice(attributes);
|
||||||
|
body.extend_from_slice(&[24, 203, 0, 113]);
|
||||||
|
let Ok(decoded) = UpdateMessage::decode(&ctx, &body) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let attributes = decoded.update.path_attributes;
|
||||||
|
let prefix = IpPrefix::try_from("203.0.113.0/24").unwrap();
|
||||||
|
let roles = ["", "customer", "peer", "provider", "route_server"];
|
||||||
|
let peer = Peer::new(
|
||||||
|
"backup-transit",
|
||||||
|
std::net::Ipv4Addr::new(192, 0, 2, 2).into(),
|
||||||
|
64602,
|
||||||
|
65000,
|
||||||
|
roles[usize::from(selector >> 1) % roles.len()],
|
||||||
|
);
|
||||||
|
let (hook, direction) = if selector & 1 == 0 {
|
||||||
|
(&HOOKS.1[0], Direction::Import)
|
||||||
|
} else {
|
||||||
|
(&HOOKS.1[1], Direction::Export)
|
||||||
|
};
|
||||||
|
let subject = Subject {
|
||||||
|
prefix: &prefix,
|
||||||
|
attributes: &attributes,
|
||||||
|
next_hop: None,
|
||||||
|
direction,
|
||||||
|
};
|
||||||
|
let Ok(verdict) = hook.run(&peer, &subject) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(changed) = verdict.attributes {
|
||||||
|
let changed: Vec<PathAttribute> = changed;
|
||||||
|
let update = UpdateMessage {
|
||||||
|
withdrawn_routes: vec![],
|
||||||
|
path_attributes: changed,
|
||||||
|
announced_routes: vec![],
|
||||||
|
};
|
||||||
|
let mut wire = BytesMut::new();
|
||||||
|
// Too long to send is a thing that can be; anything else is not.
|
||||||
|
if let Err(e) = Message::Update(update).to_wire(&ctx, &mut wire) {
|
||||||
|
assert!(
|
||||||
|
matches!(e, bgp_primitives::parser::EncodeError::TooLong { .. }),
|
||||||
|
"{e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
Binary file not shown.
+77
-1
@@ -29,7 +29,7 @@ cd "$(dirname "$0")/../.."
|
|||||||
bgp="${CARGO_TARGET_DIR:-target}/release/bgp"
|
bgp="${CARGO_TARGET_DIR:-target}/release/bgp"
|
||||||
work=/tmp/ferrix-lab
|
work=/tmp/ferrix-lab
|
||||||
pathspace=ferrixlab
|
pathspace=ferrixlab
|
||||||
all_phases="basics roles rpki refresh flaps restart scale limits hijacks nexthops v6only linklocal"
|
all_phases="basics roles rpki refresh flaps restart scale limits hijacks nexthops policy v6only linklocal"
|
||||||
phases="${PHASES:-$all_phases}"
|
phases="${PHASES:-$all_phases}"
|
||||||
scale_routes="${SCALE_ROUTES:-200000}"
|
scale_routes="${SCALE_ROUTES:-200000}"
|
||||||
|
|
||||||
@@ -130,8 +130,10 @@ END
|
|||||||
# --- the daemon under test ------------------------------------------------------
|
# --- the daemon under test ------------------------------------------------------
|
||||||
|
|
||||||
# dut_config [frr-role] [frr-max-prefixes] [more of frr4's import policy]
|
# dut_config [frr-role] [frr-max-prefixes] [more of frr4's import policy]
|
||||||
|
# [more of frr4] [more of bird4] [more of the configuration]
|
||||||
dut_config() {
|
dut_config() {
|
||||||
local frr_role=${1:-PEER} frr_limit=${2:-2000000} frr_import=${3:-}
|
local frr_role=${1:-PEER} frr_limit=${2:-2000000} frr_import=${3:-}
|
||||||
|
local frr_more=${4:-} bird_more=${5:-} more=${6:-}
|
||||||
cat > "$work/dut.textpb" <<END
|
cat > "$work/dut.textpb" <<END
|
||||||
router_id: "10.9.0.1"
|
router_id: "10.9.0.1"
|
||||||
local_asn: 64601
|
local_asn: 64601
|
||||||
@@ -143,6 +145,7 @@ peers {
|
|||||||
address_families: IPV4_UNICAST
|
address_families: IPV4_UNICAST
|
||||||
role: $frr_role
|
role: $frr_role
|
||||||
import { accept_bogons: true max_prefixes: $frr_limit hold_down_seconds: 5 $frr_import }
|
import { accept_bogons: true max_prefixes: $frr_limit hold_down_seconds: 5 $frr_import }
|
||||||
|
$frr_more
|
||||||
}
|
}
|
||||||
peers {
|
peers {
|
||||||
name: "frr6" endpoint { address: "2001:db8:9::2" } remote_asn: 64602
|
name: "frr6" endpoint { address: "2001:db8:9::2" } remote_asn: 64602
|
||||||
@@ -156,6 +159,7 @@ peers {
|
|||||||
role: PROVIDER require_role: true
|
role: PROVIDER require_role: true
|
||||||
add_path: ADD_PATH_RECEIVE
|
add_path: ADD_PATH_RECEIVE
|
||||||
import { accept_bogons: true max_prefixes: 2000000 }
|
import { accept_bogons: true max_prefixes: 2000000 }
|
||||||
|
$bird_more
|
||||||
}
|
}
|
||||||
peers {
|
peers {
|
||||||
name: "bird6" endpoint { address: "2001:db8:9::3" } remote_asn: 4200000003
|
name: "bird6" endpoint { address: "2001:db8:9::3" } remote_asn: 4200000003
|
||||||
@@ -168,6 +172,7 @@ originate { prefix: "2001:db8:1::/48" large_communities: "64601:1:2" }
|
|||||||
kernel { stale_seconds: 3 }
|
kernel { stale_seconds: 3 }
|
||||||
rpki { caches { address: "127.0.0.1" port: 3323 } retry_seconds: 1 }
|
rpki { caches { address: "127.0.0.1" port: 3323 } retry_seconds: 1 }
|
||||||
http_endpoint { address: "127.0.0.1" port: 8080 }
|
http_endpoint { address: "127.0.0.1" port: 8080 }
|
||||||
|
$more
|
||||||
END
|
END
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,6 +828,77 @@ phase_nexthops() {
|
|||||||
ok "back to normal"
|
ok "back to normal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The operator's own rules, as a script: routes are marked with where
|
||||||
|
# they were learned and ranked by it, which changes which of two routes
|
||||||
|
# is chosen; and what each neighbor is told is decided for each.
|
||||||
|
phase_policy() {
|
||||||
|
cat > "$work/policy.rhai" <<'END'
|
||||||
|
const LEARNED_FROM = #{ peer: "64601:2000", provider: "64601:3000" };
|
||||||
|
|
||||||
|
// Every route on its way in. Here the provider's routes are the ones to
|
||||||
|
// prefer, which is the other way about from what selection would do.
|
||||||
|
fn incoming(route, peer) {
|
||||||
|
route.remove_community("64601:*");
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = if peer.role == "provider" { 300 } else { 100 };
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_frr(route, peer) {
|
||||||
|
route.add_community("64601:777");
|
||||||
|
route.prepend(2);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BIRD is not told our IPv4 prefix.
|
||||||
|
fn to_bird(route, peer) { !route.within("10.1.0.0/24") }
|
||||||
|
END
|
||||||
|
dut_down TERM
|
||||||
|
dut_config PEER 2000000 "" \
|
||||||
|
'export { function: "to_frr" }' 'export { function: "to_bird" }' \
|
||||||
|
'policy { path: "policy.rhai" import_function: "incoming" }'
|
||||||
|
"$bgp" --check "$work/dut.textpb" | grep -q "frr4: import incoming, export to_frr" \
|
||||||
|
|| fail "--check does not say which functions are whose: $("$bgp" --check "$work/dut.textpb" 2>&1)"
|
||||||
|
dut_up
|
||||||
|
# A prefix nobody has said anything about in the RPKI, from both; of
|
||||||
|
# the two, FRR's would be chosen.
|
||||||
|
echo "route 10.77.0.0/24 blackhole;" > "$work/bird-both.conf"
|
||||||
|
bird_config "$work/bird-both.conf"
|
||||||
|
bird_ctl configure >/dev/null
|
||||||
|
frr_configure "router bgp 64602" "address-family ipv4 unicast" "network 10.77.0.0/24"
|
||||||
|
wait_for "four sessions" established 4
|
||||||
|
wait_for "FRR's route for it" dut_has 10.77.0.0/24 frr4
|
||||||
|
wait_for "BIRD's route for it" dut_has 10.77.0.0/24 bird4
|
||||||
|
|
||||||
|
# In: marked by where they were learned, and ranked.
|
||||||
|
local marks
|
||||||
|
marks=$(api "/api/routes/received?prefix=10.77.0.0/24" | jq -c '[.routes[] | {peer: .peer_name, marks: (tostring | [scan("64601:[0-9]+")] | unique)}] | sort_by(.peer)')
|
||||||
|
[ "$marks" = '[{"peer":"bird4","marks":["64601:3000"]},{"peer":"frr4","marks":["64601:2000"]}]' ] \
|
||||||
|
|| fail "the routes are marked $marks"
|
||||||
|
wait_for "BIRD's route to be the one chosen" best_is 10.77.0.0/24 bird4
|
||||||
|
wait_for "it in the kernel" dut_kernel -4 "10.77.0.0/24 via 10.9.0.3 dev ix0 metric 20"
|
||||||
|
peer_is frr4 '.import_function == "incoming" and .export_function == "to_frr"' >/dev/null \
|
||||||
|
|| fail "the status does not say which functions are the peer's"
|
||||||
|
ok "routes are marked with where they were learned, and the provider's is chosen and installed"
|
||||||
|
|
||||||
|
# Out: FRR is told our route with a longer path and a mark; BIRD is not
|
||||||
|
# told it at all, and is told the IPv6 one.
|
||||||
|
wait_for "our route at FRR" bash -c "sudo ip netns exec lab-frr vtysh -N $pathspace -c 'show bgp ipv4 unicast 10.1.0.0/24 json' 2>/dev/null | jq -e '.paths[0] | .aspath.string == \"64601 64601 64601\" and (.community.string | contains(\"64601:777\")) and (.community.string | contains(\"64601:100\"))'"
|
||||||
|
wait_for "our IPv6 route at BIRD" bird_has 2001:db8:1::/48
|
||||||
|
sleep 1
|
||||||
|
bird_has 10.1.0.0/24 && fail "BIRD was told the route its function refuses"
|
||||||
|
ok "FRR is told our route with a longer path and a mark; BIRD is not told it"
|
||||||
|
|
||||||
|
frr_configure "router bgp 64602" "address-family ipv4 unicast" "no network 10.77.0.0/24"
|
||||||
|
bird_config
|
||||||
|
bird_ctl configure >/dev/null
|
||||||
|
dut_down TERM
|
||||||
|
dut_config
|
||||||
|
dut_up
|
||||||
|
converged
|
||||||
|
ok "back to normal"
|
||||||
|
}
|
||||||
|
|
||||||
# The exchange LAN loses its IPv4 addresses. One session over IPv6 with
|
# The exchange LAN loses its IPv4 addresses. One session over IPv6 with
|
||||||
# each router carries both families, and IPv4 routes have IPv6 next hops
|
# each router carries both families, and IPv4 routes have IPv6 next hops
|
||||||
# (RFC 8950), in BGP and in the kernel. Run after the rest: the lab stays
|
# (RFC 8950), in BGP and in the kernel. Run after the rest: the lab stays
|
||||||
|
|||||||
Vendored
+61
@@ -0,0 +1,61 @@
|
|||||||
|
// An example policy script; see "Policy scripts" in bin/bgp/README.md for
|
||||||
|
// everything a function can read and change.
|
||||||
|
//
|
||||||
|
// A function is given a route and the peer it comes from (or goes to). It
|
||||||
|
// may change the route, and returns whether the route is taken (or
|
||||||
|
// announced). Which function is for which peer is said in the
|
||||||
|
// configuration: import { function: "from_peer" }.
|
||||||
|
//
|
||||||
|
// Constants defined here are for every function below.
|
||||||
|
|
||||||
|
// Where a route was learned: what the rules for announcing it go by.
|
||||||
|
const LEARNED_FROM = #{
|
||||||
|
customer: "65000:1000",
|
||||||
|
peer: "65000:2000",
|
||||||
|
provider: "65000:3000",
|
||||||
|
route_server: "65000:2000",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A customer's route is preferred to a peer's, and that to a provider's:
|
||||||
|
// the first earns money, the last costs it.
|
||||||
|
const PREFERENCE = #{ customer: 300, peer: 200, route_server: 200, provider: 100 };
|
||||||
|
|
||||||
|
const BLACKHOLE = "65535:666";
|
||||||
|
const NO_EXPORT = "65535:65281";
|
||||||
|
|
||||||
|
// Routes on their way in.
|
||||||
|
fn from_peer(route, peer) {
|
||||||
|
// Not ours to act on, from just anyone.
|
||||||
|
if route.has_community(BLACKHOLE) { return false; }
|
||||||
|
|
||||||
|
// Marks in our own range mean something here: nobody else's word for
|
||||||
|
// them is taken.
|
||||||
|
route.remove_community("65000:*");
|
||||||
|
if peer.role in LEARNED_FROM {
|
||||||
|
route.add_community(LEARNED_FROM[peer.role]);
|
||||||
|
route.local_pref = PREFERENCE[peer.role];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A peer can ask to be a last resort: 65000:80 on a route of its own.
|
||||||
|
if route.has_community(peer.asn % 65536, 80) {
|
||||||
|
route.local_pref = 80;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes on their way out.
|
||||||
|
fn to_peer(route, peer) {
|
||||||
|
if route.has_community(NO_EXPORT) { return false; }
|
||||||
|
|
||||||
|
// Only customers are told what we learned from peers and providers.
|
||||||
|
let from_above = route.has_community("65000:2000") || route.has_community("65000:3000");
|
||||||
|
if from_above && peer.role != "customer" { return false; }
|
||||||
|
|
||||||
|
// Our marks are our own business.
|
||||||
|
route.remove_community("65000:*");
|
||||||
|
|
||||||
|
// Traffic from this one is wanted by another way: the path looks
|
||||||
|
// longer to it.
|
||||||
|
if peer.name == "backup-transit" { route.prepend(2); }
|
||||||
|
true
|
||||||
|
}
|
||||||
Vendored
+11
@@ -48,7 +48,13 @@ peers {
|
|||||||
# as generated from the routing registries.
|
# as generated from the routing registries.
|
||||||
# prefixes { prefix: "193.0.0.0/21" max_length: 24 }
|
# prefixes { prefix: "193.0.0.0/21" max_length: 24 }
|
||||||
# origin_asns: 3333
|
# origin_asns: 3333
|
||||||
|
# And then the operator's own rules: a function of the policy
|
||||||
|
# script (see `policy` below), which may refuse the route or mark
|
||||||
|
# and rank it.
|
||||||
|
# function: "from_peer"
|
||||||
}
|
}
|
||||||
|
# What the peer is told also goes through a function of the script.
|
||||||
|
# export { function: "to_peer" }
|
||||||
}
|
}
|
||||||
connect_retry_seconds: 5
|
connect_retry_seconds: 5
|
||||||
|
|
||||||
@@ -65,6 +71,11 @@ originate {
|
|||||||
large_communities: "65000:1:2"
|
large_communities: "65000:1:2"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The operator's own rules for routes, as a script beside this file; see
|
||||||
|
# policy.rhai here for an example, and "Policy scripts" in
|
||||||
|
# bin/bgp/README.md. `bgp --check` reads it and says what is wrong.
|
||||||
|
# policy { path: "policy.rhai" }
|
||||||
|
|
||||||
# Validation with the RPKI: refuse routes whose origin AS is not the one the
|
# Validation with the RPKI: refuse routes whose origin AS is not the one the
|
||||||
# holder of the addresses authorised, and, from peers with a role, routes
|
# holder of the addresses authorised, and, from peers with a role, routes
|
||||||
# whose AS path is a leak. Point it at a validator's RTR server.
|
# whose AS path is a leak. Point it at a validator's RTR server.
|
||||||
|
|||||||
Reference in new issue
Block a user