Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
20 changes: 20 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ cargo run --example <name> --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 |
Expand All @@ -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 <mrt-file> --only-to-customer 6777

# Presence / absence wildcards (`*` = attribute present, `!*` = absent)
monocle parse <mrt-file> --only-to-customer '*'
monocle parse <mrt-file> --only-to-customer '!*'

# Select the attribute as an output column (JSON key: only_to_customer)
monocle parse <mrt-file> --fields timestamp,prefix,only-to-customer
Comment on lines +63 to +64
```

## Common Pattern
Expand Down
70 changes: 70 additions & 0 deletions examples/only_to_customer.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
59 changes: 58 additions & 1 deletion src/bin/commands/elem_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub const AVAILABLE_FIELDS: &[&str] = &[
"atomic",
"aggr_asn",
"aggr_ip",
"only-to-customer",
"collector",
];

Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"]
);
}
}
18 changes: 17 additions & 1 deletion src/bin/commands/rib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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::<Vec<_>>())
}),
"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)
Expand All @@ -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(),
}
}
Expand Down
1 change: 1 addition & 0 deletions src/bin/commands/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
22 changes: 22 additions & 0 deletions src/bin/commands/search_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ pub struct RemoteSearchFilters {
pub elem_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub as_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_to_customer: Option<String>,
pub start_ts: String,
pub end_ts: String,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -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());
}
}
Loading
Loading