diff --git a/CHANGELOG.md b/CHANGELOG.md index 22712fc..835053c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,18 +13,30 @@ All notable changes to this project will be documented in this file. also recognizes the `YYYY-MM-DD-HHMM` component in route-views snapshot filenames, using the embedded time of day. ([#145](https://github.com/bgpkit/monocle/issues/145)) -* Added nine bgpkit-parser v0.19 extended element filters to `monocle search` - and `monocle parse`: `--otc`, `--next-hop`, `--origin`, `--local-pref`, - `--med`, `--atomic-aggregate`, `--aggr-asn`, `--aggr-ip`, and - `--peer-bgp-id`. Optional-attribute filters support `*` (present) and `!*` - (absent) presence wildcards. The SSE `SearchStreamFilters` DTO exposes the - same fields for programmatic access. +* Added nine bgpkit-parser extended element filters to `monocle search` + and `monocle parse`: `--only-to-customer` (alias `--otc`), `--next-hop`, + `--origin`, `--local-pref`, `--med`, `--atomic-aggregate`, `--aggr-asn`, + `--aggr-ip`, and `--peer-bgp-id`. Optional-attribute filters support `*` + (present) and `!*` (absent) presence wildcards. The SSE `SearchStreamFilters` + DTO exposes the same fields for programmatic access. ([#148](https://github.com/bgpkit/monocle/pull/148)) +* `monocle parse`, `monocle search`, and `monocle rib` accept + `only-to-customer` as a selectable output field: `--fields only-to-customer` + displays the RFC 9234 only-to-customer ASN in JSON, table, PSV, and markdown + formats (empty/null when the attribute is absent). The custom JSON + projection emits the `only_to_customer` key to match the native element + serialization. The local RIB store (`monocle rib`) now persists the OTC + attribute for both reconstructed RIB states and the incremental updates + table, with automatic column migration for databases created before this + change, and `monocle search --remote-url` forwards the `--only-to-customer` + filter to the server. A runnable example (`cargo run --example + only_to_customer --features lib`) demonstrates value, `*` presence, and `!*` + absence filters on real Route Views data. ### Bug Fixes * Fixed `bgpkit-parser` dev-dependency version conflict: `[dev-dependencies]` - pinned v0.18.0 while the main dependency used v0.19.0, causing + pinned an older version while the main dependency used a newer one, causing `E0464: multiple candidates for rlib` on `cargo test --all-features`. ([#148](https://github.com/bgpkit/monocle/pull/148)) diff --git a/Cargo.toml b/Cargo.toml index 7bda74c..749048d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,11 @@ name = "parse_lens" path = "examples/parse_lens.rs" required-features = ["lib"] +[[example]] +name = "only_to_customer" +path = "examples/only_to_customer.rs" +required-features = ["lib"] + [[example]] name = "search_lens" path = "examples/search_lens.rs" diff --git a/README.md b/README.md index 11e0787..fe2e85d 100644 --- a/README.md +++ b/README.md @@ -512,8 +512,9 @@ Use `-f` or `--fields` to select which columns to display: # Show only prefix, as_path, and origin monocle parse file.mrt -f prefix,as_path,origin -# Available fields: type, timestamp, peer_ip, peer_asn, prefix, as_path, origin, -# next_hop, local_pref, med, communities, atomic, aggr_asn, aggr_ip, collector +# Available fields: type, timestamp, peer_ip, peer_asn, prefix, path_id, as_path, +# origin_asns, origin, next_hop, local_pref, med, communities, atomic, aggr_asn, +# aggr_ip, only-to-customer, collector ``` #### Output Sorting @@ -575,7 +576,7 @@ The output contains the following fields: | `atomic` | Atomic aggregate flag | | `aggr_asn` | Aggregator ASN | | `aggr_ip` | Aggregator IP | -| `only_to_customer` | OTC attribute (RFC 9234) | +| `only_to_customer` | OTC attribute (RFC 9234). Select it with `--fields only-to-customer`; the JSON/PSV key is `only_to_customer` | | `unknown` | Unknown attributes | | `deprecated` | Deprecated attributes | | `collector` | Collector name (for search results) | diff --git a/examples/README.md b/examples/README.md index 1d4cf40..3524690 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ cargo run --example --features lib | `country_lens` | CountryLens | Country code/name lookup | | `ip_lens` | IpLens | IP address information (ASN, RPKI, geolocation) | | `parse_lens` | ParseLens | Parse MRT files with filters | +| `only_to_customer` | ParseLens | Filter MRT data by only-to-customer ASN value / presence (RFC 9234) | | `search_lens` | SearchLens | Search BGP messages via broker | | `rpki_lens` | RpkiLens | RPKI validation for prefixes | | `pfx2as_lens` | Pfx2asLens | Prefix-to-ASN mapping lookups | @@ -42,6 +43,25 @@ cargo run --example rpki_lens --features lib # Unified inspection cargo run --example inspect_lens --features lib + +# Only-to-customer (RFC 9234) filters and display +cargo run --example only_to_customer --features lib +``` + +## Filtering and displaying only-to-customer (OTC, RFC 9234) + +Works in both `monocle parse` (single file) and `monocle search` (broker window). + +```bash +# Match a concrete ASN value +monocle parse --only-to-customer 6777 + +# Presence / absence wildcards (`*` = attribute present, `!*` = absent) +monocle parse --only-to-customer '*' +monocle parse --only-to-customer '!*' + +# Select the attribute as an output column (JSON key: only_to_customer) +monocle parse --fields timestamp,prefix,only-to-customer ``` ## Common Pattern diff --git a/examples/only_to_customer.rs b/examples/only_to_customer.rs new file mode 100644 index 0000000..1062451 --- /dev/null +++ b/examples/only_to_customer.rs @@ -0,0 +1,70 @@ +//! Only-to-Customer (RFC 9234) filters and display example +//! +//! Demonstrates filtering BGP update data by the only-to-customer attribute: +//! a concrete ASN value, `*` (attribute present), and `!*` (attribute absent), +//! via [`ParseFilters::only_to_customer`]. +//! +//! # Running +//! +//! ```bash +//! cargo run --example only_to_customer --features lib +//! ``` +//! +//! The equivalent CLI (same real data, Route Views route server peer AS37100 +//! propagating OTC values on AS12654 announcements): +//! +//! ```bash +//! monocle parse \ +//! http://archive.routeviews.org/bgpdata/2026.08/UPDATES/updates.20260816.1200.bz2 \ +//! --only-to-customer 6777 --fields timestamp,prefix,as_path,only-to-customer +//! ``` + +use monocle::lens::parse::{ParseFilters, ParseLens}; + +fn main() -> anyhow::Result<()> { + let lens = ParseLens::new(); + + // Route Views update file with real RFC 9234 OTC data: peer AS37100 (a + // route-server or provider-facing peer) tags AS12654 announcements with + // only-to-customer values such as 6777 and 8714. + let url = "http://archive.routeviews.org/bgpdata/2026.08/UPDATES/updates.20260816.1200.bz2"; + + // 1. Filter by a concrete only-to-customer ASN value. + let value_filters = ParseFilters { + only_to_customer: Some("6777".to_string()), + ..Default::default() + }; + let elems = lens.parse_with_progress(&value_filters, url, None)?; + println!("Elements with only-to-customer = 6777: {}", elems.len()); + for elem in elems.iter().take(3) { + let otc = elem + .only_to_customer + .map(|asn| asn.to_string()) + .unwrap_or_default(); + println!(" {} {} via {otc}", elem.timestamp, elem.prefix); + } + + // 2. Presence wildcard: any element that carries an only-to-customer value. + let presence_filters = ParseFilters { + only_to_customer: Some("*".to_string()), + ..Default::default() + }; + let present = lens.parse_with_progress(&presence_filters, url, None)?; + println!( + "Elements carrying an only-to-customer value: {}", + present.len() + ); + + // 3. Absence wildcard: elements without the only-to-customer attribute. + let absence_filters = ParseFilters { + only_to_customer: Some("!*".to_string()), + ..Default::default() + }; + let absent = lens.parse_with_progress(&absence_filters, url, None)?; + println!( + "Elements without an only-to-customer value: {}", + absent.len() + ); + + Ok(()) +} diff --git a/src/bin/commands/elem_format.rs b/src/bin/commands/elem_format.rs index 0428a59..1ed7ae3 100644 --- a/src/bin/commands/elem_format.rs +++ b/src/bin/commands/elem_format.rs @@ -28,6 +28,7 @@ pub const AVAILABLE_FIELDS: &[&str] = &[ "atomic", "aggr_asn", "aggr_ip", + "only-to-customer", "collector", ]; @@ -233,6 +234,11 @@ pub fn get_field_value_with_time_format( .as_ref() .map(|i| i.to_string()) .unwrap_or_default(), + "only-to-customer" => elem + .only_to_customer + .as_ref() + .map(|a| a.to_string()) + .unwrap_or_default(), "collector" => collector.unwrap_or("").to_string(), _ => String::new(), } @@ -367,13 +373,25 @@ pub fn build_json_object( Some(i) => json!(i.to_string()), None => serde_json::Value::Null, }, + "only-to-customer" => match &elem.only_to_customer { + Some(a) => json!(a), + None => serde_json::Value::Null, + }, "collector" => match collector { Some(c) => json!(c), None => serde_json::Value::Null, }, _ => serde_json::Value::Null, }; - obj.insert((*field).to_string(), value); + // The CLI field name uses the "only-to-customer" kebab-case spelling, + // but the JSON key follows the BgpElem serde field name (only_to_customer) + // so custom projection matches the native element serialization. + let key = if *field == "only-to-customer" { + "only_to_customer" + } else { + *field + }; + obj.insert(key.to_string(), value); } serde_json::Value::Object(obj) @@ -579,4 +597,43 @@ mod tests { assert!(ts.is_string(), "rfc3339 timestamp should be a string"); assert!(ts.as_str().unwrap().contains('T')); } + + #[test] + fn test_only_to_customer_field_selection() { + let mut elem = test_elem(); + elem.only_to_customer = Some(65001.into()); + + // get_field_value returns the ASN when present, empty when absent + assert_eq!( + get_field_value_with_time_format( + &elem, + "only-to-customer", + None, + TimestampFormat::Unix + ), + "65001" + ); + assert_eq!( + get_field_value_with_time_format( + &test_elem(), + "only-to-customer", + None, + TimestampFormat::Unix + ), + "" + ); + + // Custom JSON projection emits the numeric ASN under the snake_case key, + // matching the native BgpElem serialization used by default JSON output. + let fields = vec!["timestamp", "only-to-customer"]; + let obj = build_json_object(&elem, &fields, None, TimestampFormat::Unix); + assert_eq!(obj["only_to_customer"], 65001); + assert!(obj.get("only-to-customer").is_none()); + + // parse_fields accepts "only-to-customer" as a selectable output field + assert_eq!( + parse_fields(&Some("only-to-customer".to_string()), false).unwrap(), + vec!["only-to-customer"] + ); + } } diff --git a/src/bin/commands/rib.rs b/src/bin/commands/rib.rs index af3f966..563b500 100644 --- a/src/bin/commands/rib.rs +++ b/src/bin/commands/rib.rs @@ -23,6 +23,7 @@ const DEFAULT_FIELDS_RIB: &[&str] = &[ "prefix", "as_path", "origin_asns", + "only-to-customer", ]; pub fn run(config: &MonocleConfig, args: RibArgs, output_format: OutputFormat, no_update: bool) { @@ -228,10 +229,21 @@ fn build_json_object(entry: &StoredRibEntry, fields: &[&str]) -> serde_json::Val .map_or(serde_json::Value::Null, |values| { json!(values.iter().map(u32::to_string).collect::>()) }), + "only-to-customer" => entry + .only_to_customer + .map_or(serde_json::Value::Null, |value| json!(value)), _ => serde_json::Value::Null, }; - obj.insert((*field).to_string(), value); + // The CLI field name uses the "only-to-customer" kebab-case spelling, + // but the JSON key follows the BgpElem serde field name (only_to_customer) + // so custom projection matches the native element serialization. + let key = if *field == "only-to-customer" { + "only_to_customer" + } else { + *field + }; + obj.insert(key.to_string(), value); } serde_json::Value::Object(obj) @@ -246,6 +258,10 @@ fn entry_field_value(entry: &StoredRibEntry, field: &str) -> String { "prefix" => entry.prefix.to_string(), "as_path" => entry.as_path.clone().unwrap_or_default(), "origin_asns" => entry.origin_asns_string().unwrap_or_default(), + "only-to-customer" => entry + .only_to_customer + .map(|v| v.to_string()) + .unwrap_or_default(), _ => String::new(), } } diff --git a/src/bin/commands/search.rs b/src/bin/commands/search.rs index bd51504..8de264e 100644 --- a/src/bin/commands/search.rs +++ b/src/bin/commands/search.rs @@ -1513,6 +1513,7 @@ fn run_remote_search_wrapper( monocle::lens::parse::ParseElemType::W => "W".to_string(), }), as_path: filters.parse_filters.as_path.clone(), + only_to_customer: filters.parse_filters.only_to_customer.clone(), start_ts: filters.parse_filters.start_ts.clone().unwrap_or_default(), end_ts: filters.parse_filters.end_ts.clone().unwrap_or_default(), collector: filters.collector.clone(), diff --git a/src/bin/commands/search_remote.rs b/src/bin/commands/search_remote.rs index 0eeba6a..16d2859 100644 --- a/src/bin/commands/search_remote.rs +++ b/src/bin/commands/search_remote.rs @@ -46,6 +46,8 @@ pub struct RemoteSearchFilters { pub elem_type: Option, #[serde(skip_serializing_if = "Option::is_none")] pub as_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub only_to_customer: Option, pub start_ts: String, pub end_ts: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -240,3 +242,23 @@ pub async fn run_remote_search( "remote search ended without completion event" )) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn test_remote_filters_serialize_only_to_customer() { + let filters = RemoteSearchFilters { + only_to_customer: Some("6777".to_string()), + start_ts: "1".to_string(), + end_ts: "2".to_string(), + ..Default::default() + }; + let json = serde_json::to_value(&filters).unwrap(); + assert_eq!(json["only_to_customer"], "6777"); + // Unset optional dimensions are omitted from the wire payload + assert!(json.get("next_hop").is_none()); + } +} diff --git a/src/database/session/rib_store.rs b/src/database/session/rib_store.rs index 6066da1..199b962 100644 --- a/src/database/session/rib_store.rs +++ b/src/database/session/rib_store.rs @@ -52,6 +52,8 @@ pub struct StoredRibEntry { pub path_id: Option, pub as_path: Option, pub origin_asns: Option>, + /// RFC 9234 only-to-customer ASN, when the attribute is present + pub only_to_customer: Option, } impl StoredRibEntry { @@ -67,6 +69,7 @@ impl StoredRibEntry { origin_asns: elem .origin_asns .map(|asns| asns.into_iter().map(|asn| asn.to_u32()).collect::>()), + only_to_customer: elem.only_to_customer.map(|asn| asn.to_u32()), } } @@ -99,6 +102,8 @@ pub struct StoredRibUpdate { pub path_id: Option, pub as_path: Option, pub origin_asns: Option>, + /// RFC 9234 only-to-customer ASN, when the attribute is present + pub only_to_customer: Option, /// The type of BGP message (ANNOUNCE or WITHDRAW) pub elem_type: ElemType, } @@ -117,6 +122,7 @@ impl StoredRibUpdate { origin_asns: elem .origin_asns .map(|asns| asns.into_iter().map(|asn| asn.to_u32()).collect::>()), + only_to_customer: elem.only_to_customer.map(|asn| asn.to_u32()), elem_type, } } @@ -242,7 +248,8 @@ impl RibSqliteStore { prefix TEXT NOT NULL, path_id INTEGER, as_path TEXT, - origin_asns TEXT + origin_asns TEXT, + only_to_customer INTEGER ); -- Filtered BGP updates used to build 2nd and later RIB snapshots @@ -258,11 +265,48 @@ impl RibSqliteStore { path_id INTEGER, as_path TEXT, origin_asns TEXT, + only_to_customer INTEGER, elem_type TEXT NOT NULL ); "#, ) .map_err(|e| anyhow!("Failed to initialize RIB SQLite schema: {}", e))?; + + // Migrate databases created by older Monocle releases: the + // only_to_customer column did not exist before, and opening such a + // database with reset=false leaves the tables unchanged (CREATE TABLE + // IF NOT EXISTS is a no-op), so the new INSERT statements would fail + // with "no column named only_to_customer". + self.ensure_column("ribs", "only_to_customer", "INTEGER")?; + self.ensure_column("updates", "only_to_customer", "INTEGER")?; + Ok(()) + } + + /// Add a column to a table if it does not already exist, so databases + /// created before the column was introduced can still be opened and written. + fn ensure_column(&self, table: &str, column: &str, decl: &str) -> Result<()> { + let exists = { + let mut stmt = self + .db + .conn + .prepare(&format!("PRAGMA table_info({})", table)) + .map_err(|e| anyhow!("Failed to inspect {}.{}: {}", table, column, e))?; + let names = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| anyhow!("Failed to read {} columns: {}", table, e))? + .collect::, _>>() + .map_err(|e| anyhow!("Failed to collect {} columns: {}", table, e))?; + names.iter().any(|n| n == column) + }; + if !exists { + self.db + .conn + .execute( + &format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, decl), + [], + ) + .map_err(|e| anyhow!("Failed to add column {}.{}: {}", table, column, e))?; + } Ok(()) } @@ -290,9 +334,9 @@ impl RibSqliteStore { .prepare_cached( r#" INSERT INTO ribs ( - rib_ts, timestamp, collector, peer_ip, peer_asn, - prefix, path_id, as_path, origin_asns - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + rib_ts, timestamp, collector, peer_ip, peer_asn, + prefix, path_id, as_path, origin_asns, only_to_customer + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) "#, ) .map_err(|e| anyhow!("Failed to prepare ribs insert statement: {}", e))?; @@ -309,6 +353,7 @@ impl RibSqliteStore { entry.path_id, entry.as_path, entry.origin_asns_string(), + entry.only_to_customer, ]) .map_err(|e| anyhow!("Failed to insert into ribs table: {}", e))?; Ok(()) @@ -322,8 +367,8 @@ impl RibSqliteStore { r#" INSERT INTO updates ( rib_ts, timestamp, collector, peer_ip, peer_asn, - prefix, path_id, as_path, origin_asns, elem_type - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + prefix, path_id, as_path, origin_asns, only_to_customer, elem_type + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) "#, ) .map_err(|e| anyhow!("Failed to prepare updates insert statement: {}", e))?; @@ -345,6 +390,7 @@ impl RibSqliteStore { update.path_id, update.as_path, update.origin_asns_string(), + update.only_to_customer, elem_type_str, ]) .map_err(|e| anyhow!("Failed to insert into updates table: {}", e))?; @@ -410,7 +456,7 @@ mod tests { atomic: false, aggr_asn: None, aggr_ip: None, - only_to_customer: None, + only_to_customer: Some(64497.into()), unknown: None, deprecated: None, }) @@ -432,6 +478,8 @@ mod tests { assert_eq!(visited.len(), 1); assert_eq!(visited[0].collector.as_ref(), "rrc00"); assert_eq!(visited[0].path_id, Some(7)); + // The only-to-customer attribute must survive the BgpElem conversion + assert_eq!(visited[0].only_to_customer, Some(64497)); Ok(()) } @@ -487,6 +535,98 @@ mod tests { // Only 2nd RIB has updates stored assert_eq!(update_count, 1); + // The only-to-customer ASN is persisted, not written as NULL + let rib_otc: Option = store + .db + .conn + .query_row("SELECT only_to_customer FROM ribs LIMIT 1", [], |row| { + row.get(0) + }) + .map_err(|e| anyhow!("Failed to read ribs only_to_customer: {}", e))?; + assert_eq!(rib_otc, Some(64497)); + + let update_otc: Option = store + .db + .conn + .query_row("SELECT only_to_customer FROM updates LIMIT 1", [], |row| { + row.get(0) + }) + .map_err(|e| anyhow!("Failed to read updates only_to_customer: {}", e))?; + assert_eq!(update_otc, Some(64497)); + + Ok(()) + } + + #[test] + fn test_sqlite_store_migrates_old_schema() -> Result<()> { + use rusqlite::Connection; + use tempfile::NamedTempFile; + + let temp_file = NamedTempFile::new()?; + let path = temp_file.path().to_str().unwrap(); + + // Simulate a database created by an older Monocle release: the + // only_to_customer column did not exist in either table. + { + let conn = Connection::open(path)?; + conn.execute_batch( + r#" + CREATE TABLE ribs ( + rib_ts INTEGER NOT NULL, timestamp REAL NOT NULL, + collector TEXT NOT NULL, peer_ip TEXT NOT NULL, + peer_asn INTEGER NOT NULL, prefix TEXT NOT NULL, + path_id INTEGER, as_path TEXT, origin_asns TEXT + ); + CREATE TABLE updates ( + rib_ts INTEGER NOT NULL, timestamp REAL NOT NULL, + collector TEXT NOT NULL, peer_ip TEXT NOT NULL, + peer_asn INTEGER NOT NULL, prefix TEXT NOT NULL, + path_id INTEGER, as_path TEXT, origin_asns TEXT, + elem_type TEXT NOT NULL + ); + "#, + )?; + } + + // Open without reset: the missing columns must be added automatically. + let mut store = RibSqliteStore::new(path, false)?; + + let mut state = RibStateStore::new_temp()?; + let entry = StoredRibEntry::from_elem(Arc::from("rrc00"), test_elem()?); + state.upsert_entry(entry)?; + let update = StoredRibUpdate::from_elem( + 1704069000, + Arc::from("rrc00"), + test_elem()?, + ElemType::ANNOUNCE, + ); + + // First snapshot: ribs only (snapshot_index 0 does not store updates) + store.insert_snapshot(1704067200, &state, &[])?; + // Second snapshot: stores the updates, exercising the insert path that + // depends on the migrated column. + store.insert_snapshot(1704069000, &state, &[update])?; + store.finalize_indexes()?; + + // After migration, inserts succeed and the values are the migrated type + let rib_otc: Option = store + .db + .conn + .query_row("SELECT only_to_customer FROM ribs LIMIT 1", [], |row| { + row.get(0) + }) + .map_err(|e| anyhow!("Failed to read migrated ribs only_to_customer: {}", e))?; + assert_eq!(rib_otc, Some(64497)); + + let update_otc: Option = store + .db + .conn + .query_row("SELECT only_to_customer FROM updates LIMIT 1", [], |row| { + row.get(0) + }) + .map_err(|e| anyhow!("Failed to read migrated updates only_to_customer: {}", e))?; + assert_eq!(update_otc, Some(64497)); + Ok(()) } } diff --git a/src/lens/parse/mod.rs b/src/lens/parse/mod.rs index c96c5c5..36335b3 100644 --- a/src/lens/parse/mod.rs +++ b/src/lens/parse/mod.rs @@ -279,14 +279,18 @@ pub struct ParseFilters { #[cfg_attr(feature = "cli", clap(short = 'a', long))] pub as_path: Option, - // --- bgpkit-parser v0.19 extended element filters --- + // --- bgpkit-parser extended element filters --- // Each accepts the literal value, or `*` (present) / `!*` (absent) for - // optional fields (otc, next_hop, origin, local_pref, med, aggr_asn, - // aggr_ip, peer_bgp_id). + // optional fields (only_to_customer, next_hop, origin, local_pref, med, + // aggr_asn, aggr_ip, peer_bgp_id). /// Filter by only-to-customer ASN (RFC 9234). Use `*`/`!*` for presence. - #[cfg_attr(feature = "cli", clap(long, visible_alias = "otc-asn"))] + /// Maps to the bgpkit-parser filter key `otc` internally. + #[cfg_attr( + feature = "cli", + clap(long = "only-to-customer", visible_alias = "otc") + )] #[serde(default)] - pub otc: Option, + pub only_to_customer: Option, /// Filter by next-hop IP address. Use `*`/`!*` for presence. #[cfg_attr(feature = "cli", clap(long))] @@ -608,7 +612,7 @@ impl ParseFilters { Ok(()) } - validate_u32_field(&self.otc, "otc")?; + validate_u32_field(&self.only_to_customer, "only-to-customer")?; validate_u32_field(&self.local_pref, "local-pref")?; validate_u32_field(&self.med, "med")?; validate_u32_field(&self.aggr_asn, "aggr-asn")?; @@ -691,8 +695,9 @@ impl ParseFilters { specs.push(("type", value.to_string())); } - // --- bgpkit-parser v0.19 extended element filters --- - if let Some(v) = &self.otc { + // --- bgpkit-parser extended element filters --- + if let Some(v) = &self.only_to_customer { + // bgpkit-parser's filter key for the only-to-customer attribute is `otc` specs.push(("otc", v.clone())); } if let Some(v) = &self.next_hop { @@ -1294,7 +1299,7 @@ mod tests { fn test_validate_extended_filters_valid() { // All valid v0.19 filter values let filters = ParseFilters { - otc: Some("65200".to_string()), + only_to_customer: Some("65200".to_string()), next_hop: Some("10.0.0.1".to_string()), origin: Some("igp".to_string()), local_pref: Some("100".to_string()), @@ -1309,7 +1314,7 @@ mod tests { // Presence wildcards let filters = ParseFilters { - otc: Some("*".to_string()), + only_to_customer: Some("*".to_string()), next_hop: Some("*".to_string()), origin: Some("*".to_string()), local_pref: Some("*".to_string()), @@ -1323,7 +1328,7 @@ mod tests { // Absence wildcards let filters = ParseFilters { - otc: Some("!*".to_string()), + only_to_customer: Some("!*".to_string()), next_hop: Some("!*".to_string()), ..Default::default() }; @@ -1331,7 +1336,7 @@ mod tests { // Negated concrete values let filters = ParseFilters { - otc: Some("!65200".to_string()), + only_to_customer: Some("!65200".to_string()), origin: Some("!igp".to_string()), ..Default::default() }; @@ -1349,7 +1354,7 @@ mod tests { fn test_validate_extended_filters_invalid() { // Invalid u32 for otc let filters = ParseFilters { - otc: Some("not-a-number".to_string()), + only_to_customer: Some("not-a-number".to_string()), ..Default::default() }; assert!(filters.validate().is_err()); @@ -1388,7 +1393,7 @@ mod tests { // Verify that the v0.19 filter specs are correctly emitted for // bgpkit-parser consumption via Filter::new. let filters = ParseFilters { - otc: Some("65200".to_string()), + only_to_customer: Some("65200".to_string()), next_hop: Some("10.0.0.1".to_string()), origin: Some("igp".to_string()), local_pref: Some("100".to_string()), diff --git a/src/server/search.rs b/src/server/search.rs index 395eb15..0bfe891 100644 --- a/src/server/search.rs +++ b/src/server/search.rs @@ -72,9 +72,9 @@ pub struct SearchStreamFilters { pub elem_type: Option, #[serde(default)] pub as_path: Option, - // --- bgpkit-parser v0.19 extended element filters --- + // --- bgpkit-parser extended element filters --- #[serde(default)] - pub otc: Option, + pub only_to_customer: Option, #[serde(default)] pub next_hop: Option, #[serde(default)] @@ -150,7 +150,7 @@ impl TryFrom for SearchFilters { end_ts: Some(f.end_ts), duration: None, as_path: f.as_path, - otc: f.otc, + only_to_customer: f.only_to_customer, next_hop: f.next_hop, origin: f.origin, local_pref: f.local_pref,