Skip to content

Commit add4c8e

Browse files
authored
Merge pull request #88 from bgpkit/feature/as2rel
feat: add as2rel command for AS-level relationship lookup
2 parents 9dbefe3 + 429d776 commit add4c8e

File tree

9 files changed

+1254
-0
lines changed

9 files changed

+1254
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212

1313
.claude
1414
CLAUDE.md
15+
test_as2rel.sqlite3*

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ All notable changes to this project will be documented in this file.
2929
* Results grouped by customer ASN with providers as comma-separated list (table) or array (JSON)
3030
* Supports JSON output with `--json` flag
3131

32+
* **New `as2rel` command**: AS-level relationship lookup between ASNs
33+
* Query relationships for one or two ASNs from BGPKIT's AS relationship data
34+
* Data source: `https://data.bgpkit.com/as2rel/as2rel-latest.json.bz2`
35+
* Output columns:
36+
- `connected`: Percentage of route collectors that see any connection between asn1 and asn2
37+
- `peer`: Percentage seeing pure peering only (connected - as1_upstream - as2_upstream)
38+
- `as1_upstream`: Percentage of route collectors that see asn1 as an upstream of asn2
39+
- `as2_upstream`: Percentage of route collectors that see asn2 as an upstream of asn1
40+
* Percentages calculated as `count / max_peers_count * 100%`
41+
* Displays last update time with human-readable relative time (e.g., "2 days ago")
42+
* Local SQLite caching with automatic updates when data is older than 7 days
43+
* `--update`: Force update the local database
44+
* `--update-with <PATH>`: Update with a custom data file (local path or URL)
45+
* `--pretty`: Output to pretty table (default: markdown table)
46+
* `--no-explain`: Hide the explanation text in table output
47+
* `--sort-by-asn`: Sort results by ASN2 ascending (default: sort by connected % descending)
48+
* `--show-name`: Show organization name for ASN2 from local as2org database (truncated to 20 chars)
49+
* Supports JSON output with `--json` flag
50+
3251
* **JSON output support**: All RPKI commands now support `--json` flag for JSON output
3352
* `rpki check`: Returns validation result and covering ROAs as JSON
3453
* `rpki list`: Returns ROAs as JSON array

README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ See through all Border Gateway Protocol (BGP) data with a monocle.
2323
- [`monocle time`](#monocle-time)
2424
- [`monocle whois`](#monocle-whois)
2525
- [`monocle country`](#monocle-country)
26+
- [`monocle as2rel`](#monocle-as2rel)
2627
- [`monocle rpki`](#monocle-rpki)
2728
- [`monocle rpki check`](#monocle-rpki-check)
2829
- [`monocle rpki list`](#monocle-rpki-list)
@@ -475,6 +476,92 @@ Example runs:
475476
╰──────┴──────────────────────────────────────╯
476477
```
477478

479+
### `monocle as2rel`
480+
481+
Query AS-level relationships between Autonomous Systems using BGPKIT's AS relationship data.
482+
483+
Data source: [BGPKIT AS2Rel](https://data.bgpkit.com/as2rel/)
484+
485+
```text
486+
➜ monocle as2rel --help
487+
AS-level relationship lookup between ASNs
488+
489+
Usage: monocle as2rel [OPTIONS] <ASNS>...
490+
491+
Arguments:
492+
<ASNS>... One or two ASNs to query relationships for
493+
494+
Options:
495+
-u, --update Force update the local as2rel database
496+
--update-with <UPDATE_WITH> Update with a custom data file (local path or URL)
497+
-p, --pretty Output to pretty table, default markdown table
498+
--no-explain Hide the explanation text
499+
--sort-by-asn Sort by ASN2 ascending instead of connected percentage descending
500+
--show-name Show organization name for ASN2 (from as2org database)
501+
--json Output as JSON objects
502+
-h, --help Print help
503+
```
504+
505+
Query relationship between two ASNs (e.g., Hurricane Electric and Cloudflare):
506+
507+
```text
508+
➜ monocle as2rel 6939 13335
509+
510+
Relationship data from BGPKIT (data.bgpkit.com/as2rel).
511+
Last updated: 2025-12-09T21:26:36+00:00 (2 hours ago)
512+
513+
Column explanation:
514+
- asn1, asn2: The AS pair being queried
515+
- connected: Percentage of route collectors (1831 max) that see any connection between asn1 and asn2
516+
- peer: Percentage seeing pure peering only (connected - as1_upstream - as2_upstream)
517+
- as1_upstream: Percentage of route collectors that see asn1 as an upstream of asn2
518+
- as2_upstream: Percentage of route collectors that see asn2 as an upstream of asn1
519+
520+
Percentages are calculated as: (count / max_peers_count) * 100%
521+
where max_peers_count = 1831 (the maximum peers_count observed in the dataset).
522+
523+
| asn1 | asn2 | connected | peer | as1_upstream | as2_upstream |
524+
|------|-------|-----------|------|--------------|--------------|
525+
| 6939 | 13335 | 26.2% | 1.3% | 24.9% | |
526+
```
527+
528+
Query all relationships for a single ASN (sorted by connected % by default):
529+
530+
```text
531+
➜ monocle as2rel --no-explain 400644
532+
| asn1 | asn2 | connected | peer | as1_upstream | as2_upstream |
533+
|--------|-------|-----------|-------|--------------|--------------|
534+
| 400644 | 20473 | 78.0% | 12.1% | | 65.9% |
535+
```
536+
537+
Show organization names for ASN2:
538+
539+
```text
540+
➜ monocle as2rel --no-explain --show-name 400644
541+
| asn1 | asn2 | asn2_name | connected | peer | as1_upstream | as2_upstream |
542+
|--------|-------|----------------------|-----------|-------|--------------|--------------|
543+
| 400644 | 20473 | The Constant Comp... | 78.0% | 12.1% | | 65.9% |
544+
```
545+
546+
JSON output:
547+
548+
```text
549+
➜ monocle --json as2rel 6939 13335
550+
{
551+
"max_peers_count": 1831,
552+
"results": [
553+
{
554+
"as1_upstream": "24.9%",
555+
"as2_upstream": "",
556+
"asn1": 6939,
557+
"asn2": 13335,
558+
"connected": "26.2%",
559+
"peer": "1.3%"
560+
}
561+
]
562+
}
563+
```
564+
478565
### `monocle rpki`:
479566

480567
RPKI utilities for checking validity, listing ROAs/ASPAs, and querying historical RPKI data.

src/bin/commands/as2rel.rs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
use clap::Args;
2+
use monocle::{As2rel, As2relSearchResult, As2relSortOrder, MonocleConfig};
3+
use serde_json::json;
4+
use tabled::settings::Style;
5+
use tabled::Table;
6+
7+
/// Arguments for the As2rel command
8+
#[derive(Args)]
9+
pub struct As2relArgs {
10+
/// One or two ASNs to query relationships for
11+
#[clap(required = true)]
12+
pub asns: Vec<u32>,
13+
14+
/// Force update the local as2rel database
15+
#[clap(short, long)]
16+
pub update: bool,
17+
18+
/// Update with a custom data file (local path or URL)
19+
#[clap(long)]
20+
pub update_with: Option<String>,
21+
22+
/// Output to pretty table, default markdown table
23+
#[clap(short, long)]
24+
pub pretty: bool,
25+
26+
/// Hide the explanation text
27+
#[clap(long)]
28+
pub no_explain: bool,
29+
30+
/// Sort by ASN2 ascending instead of connected percentage descending
31+
#[clap(long)]
32+
pub sort_by_asn: bool,
33+
34+
/// Show organization name for ASN2 (from as2org database)
35+
#[clap(long)]
36+
pub show_name: bool,
37+
}
38+
39+
pub fn run(config: &MonocleConfig, args: As2relArgs, json_output: bool) {
40+
let As2relArgs {
41+
asns,
42+
update,
43+
update_with,
44+
pretty,
45+
no_explain,
46+
sort_by_asn,
47+
show_name,
48+
} = args;
49+
50+
if asns.is_empty() || asns.len() > 2 {
51+
eprintln!("ERROR: Please provide one or two ASNs");
52+
std::process::exit(1);
53+
}
54+
55+
let data_dir = config.data_dir.as_str();
56+
let db_path = format!("{data_dir}/monocle-data.sqlite3");
57+
let as2rel = match As2rel::new(&Some(db_path.clone())) {
58+
Ok(as2rel) => as2rel,
59+
Err(e) => {
60+
eprintln!("Failed to create AS2rel database: {}", e);
61+
std::process::exit(1);
62+
}
63+
};
64+
65+
// Handle updates
66+
if update || update_with.is_some() {
67+
println!("Updating AS2rel data...");
68+
let result = match &update_with {
69+
Some(path) => as2rel.update_with(path),
70+
None => as2rel.update(),
71+
};
72+
if let Err(e) = result {
73+
eprintln!("Failed to update AS2rel data: {}", e);
74+
std::process::exit(1);
75+
}
76+
println!("AS2rel data updated successfully");
77+
}
78+
79+
// Check if data needs to be initialized or updated
80+
if as2rel.should_update() && !update && update_with.is_none() {
81+
println!("AS2rel data is empty or outdated, updating now...");
82+
if let Err(e) = as2rel.update() {
83+
eprintln!("Failed to update AS2rel data: {}", e);
84+
std::process::exit(1);
85+
}
86+
println!("AS2rel data updated successfully");
87+
}
88+
89+
// Query relationships (use JOIN-based lookup if names are requested)
90+
let mut results: Vec<As2relSearchResult> = match asns.len() {
91+
1 => {
92+
let asn = asns[0];
93+
let search_result = if show_name {
94+
as2rel.search_asn_with_names(asn)
95+
} else {
96+
as2rel.search_asn(asn)
97+
};
98+
match search_result {
99+
Ok(r) => r,
100+
Err(e) => {
101+
eprintln!("Error searching for ASN {}: {}", asn, e);
102+
std::process::exit(1);
103+
}
104+
}
105+
}
106+
2 => {
107+
let asn1 = asns[0];
108+
let asn2 = asns[1];
109+
let search_result = if show_name {
110+
as2rel.search_pair_with_names(asn1, asn2)
111+
} else {
112+
as2rel.search_pair(asn1, asn2)
113+
};
114+
match search_result {
115+
Ok(r) => r,
116+
Err(e) => {
117+
eprintln!("Error searching for ASN pair {} - {}: {}", asn1, asn2, e);
118+
std::process::exit(1);
119+
}
120+
}
121+
}
122+
_ => {
123+
eprintln!("ERROR: Please provide one or two ASNs");
124+
std::process::exit(1);
125+
}
126+
};
127+
128+
if results.is_empty() {
129+
if asns.len() == 1 {
130+
println!("No relationships found for ASN {}", asns[0]);
131+
} else {
132+
println!(
133+
"No relationship found between ASN {} and ASN {}",
134+
asns[0], asns[1]
135+
);
136+
}
137+
return;
138+
}
139+
140+
// Sort results
141+
let sort_order = if sort_by_asn {
142+
As2relSortOrder::Asn2Asc
143+
} else {
144+
As2relSortOrder::ConnectedDesc
145+
};
146+
As2rel::sort_results(&mut results, sort_order);
147+
148+
// Output results
149+
if json_output {
150+
let max_peers = as2rel.get_max_peers_count();
151+
let json_results: Vec<_> = results
152+
.iter()
153+
.map(|r| {
154+
if show_name {
155+
json!({
156+
"asn1": r.asn1,
157+
"asn2": r.asn2,
158+
"asn2_name": r.asn2_name.as_deref().unwrap_or(""),
159+
"connected": &r.connected,
160+
"peer": &r.peer,
161+
"as1_upstream": &r.as1_upstream,
162+
"as2_upstream": &r.as2_upstream,
163+
})
164+
} else {
165+
json!({
166+
"asn1": r.asn1,
167+
"asn2": r.asn2,
168+
"connected": &r.connected,
169+
"peer": &r.peer,
170+
"as1_upstream": &r.as1_upstream,
171+
"as2_upstream": &r.as2_upstream,
172+
})
173+
}
174+
})
175+
.collect();
176+
let output = json!({
177+
"max_peers_count": max_peers,
178+
"results": json_results,
179+
});
180+
match serde_json::to_string_pretty(&output) {
181+
Ok(s) => println!("{}", s),
182+
Err(e) => eprintln!("Error serializing JSON: {}", e),
183+
}
184+
} else {
185+
// Print explanation unless --no-explain is set
186+
if !no_explain {
187+
println!("{}", as2rel.get_explanation());
188+
}
189+
190+
if show_name {
191+
let results_with_name: Vec<_> = results.into_iter().map(|r| r.with_name()).collect();
192+
let mut table = Table::new(&results_with_name);
193+
if pretty {
194+
println!("{}", table.with(Style::rounded()));
195+
} else {
196+
println!("{}", table.with(Style::markdown()));
197+
}
198+
} else {
199+
let mut table = Table::new(&results);
200+
if pretty {
201+
println!("{}", table.with(Style::rounded()));
202+
} else {
203+
println!("{}", table.with(Style::markdown()));
204+
}
205+
}
206+
}
207+
}

src/bin/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod as2rel;
12
pub mod broker;
23
pub mod country;
34
pub mod ip;

src/bin/monocle.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use tracing::Level;
1111
mod commands;
1212

1313
// Re-export argument types from command modules for use in the Commands enum
14+
use commands::as2rel::As2relArgs;
1415
use commands::broker::BrokerArgs;
1516
use commands::country::CountryArgs;
1617
use commands::ip::IpArgs;
@@ -79,6 +80,9 @@ enum Commands {
7980

8081
/// Bulk prefix-to-AS mapping lookup with the pre-generated data file.
8182
Pfx2as(Pfx2asArgs),
83+
84+
/// AS-level relationship lookup between ASNs.
85+
As2rel(As2relArgs),
8286
}
8387

8488
pub(crate) fn elem_to_string(
@@ -136,5 +140,6 @@ fn main() {
136140
Commands::Radar { commands } => commands::radar::run(commands, json),
137141
Commands::Ip(args) => commands::ip::run(args, json),
138142
Commands::Pfx2as(args) => commands::pfx2as::run(args, json),
143+
Commands::As2rel(args) => commands::as2rel::run(&config, args, json),
139144
}
140145
}

0 commit comments

Comments
 (0)