use crate::Message;
#[derive(Debug, Clone)]
pub struct Protocol {
pub name: String,
pub proto: String,
pub table: Option<String>,
pub state: String,
pub since: String,
pub info: Option<String>,
}
impl Protocol {
pub fn from_enum(message: &Message) -> Option<Vec<Protocol>> {
if let Message::ProtocolList(content) = message {
let mut result = vec![];
for line in content.lines() {
let (name, next) = line.split_once(" ")?;
let (proto, next) = next.trim_start().split_once(" ")?;
let (table, next) = next.trim_start().split_once(" ")?;
let (state, next) = next.trim_start().split_once(" ")?;
let next = next.trim();
let (since, info) = match next.split_once(" ") {
None => (next, None),
Some((since, info)) => (since, Some(info.trim_start().to_owned())),
};
result.push(Protocol {
name: name.to_owned(),
proto: proto.to_owned(),
table: filler_to_option(table),
state: state.to_owned(),
since: since.to_owned(),
info,
})
}
Some(result)
} else {
None
}
}
}
#[derive(Debug)]
pub struct ProtocolDetail {
pub description: Option<String>,
pub message: Option<String>,
pub router_id: Option<String>,
pub vrf: Option<String>,
pub proto_info: Option<ProtoSpecificInfo>,
pub channels: Vec<Channel>,
}
impl ProtocolDetail {
pub fn from_enum(message: &Message) -> Option<ProtocolDetail> {
if let Message::ProtocolDetails(content) = message {
let lines: Vec<&str> = content.lines().filter(|x| !x.is_empty()).collect();
if lines.is_empty() {
return None;
}
let mut description: Option<&str> = None;
let mut message: Option<&str> = None;
let mut router_id: Option<&str> = None;
let mut vrf: Option<&str> = None;
let mut proto_info: Option<ProtoSpecificInfo> = None;
let mut channels: Vec<Channel> = vec![];
let base_indent = indent_level(lines[0]);
let mut idx = 0_usize;
while idx < lines.len() {
let line = &lines[idx][base_indent..];
if let Some(s) = line.strip_prefix("Description:") {
description = Some(s.trim());
} else if let Some(s) = line.strip_prefix("Message:") {
message = Some(s.trim());
} else if let Some(s) = line.strip_prefix("Router ID:") {
router_id = Some(s.trim());
} else if let Some(s) = line.strip_prefix("VRF:") {
vrf = Some(s.trim());
} else if line.starts_with("Channel") {
let start_idx = idx;
idx = lines
.iter()
.skip(idx + 1)
.position(|it| indent_level(it) == base_indent)
.unwrap_or(lines.len() - (idx + 1))
+ (idx + 1);
if let Some(channel) = Channel::from_lines(&lines[start_idx..idx]) {
channels.push(channel);
}
idx -= 1; } else if line.starts_with("BGP") {
let start_idx = idx;
idx = lines
.iter()
.skip(idx + 1)
.position(|it| indent_level(it) == base_indent)
.unwrap_or(lines.len() - (idx + 1))
+ (idx + 1);
if let Some(bgp_info) = BgpInfo::from_lines(&lines[start_idx..idx]) {
proto_info = Some(ProtoSpecificInfo::Bgp(bgp_info));
}
idx -= 1; }
idx += 1;
}
Some(ProtocolDetail {
description: description.map(|x| x.to_owned()),
message: message.map(|x| x.to_owned()),
router_id: router_id.map(|x| x.to_owned()),
vrf: vrf.map(|x| x.to_owned()),
proto_info,
channels,
})
} else {
None
}
}
}
#[derive(Debug)]
pub enum ProtoSpecificInfo {
Bgp(BgpInfo),
}
#[derive(Debug)]
pub struct Channel {
pub name: String,
pub state: String,
pub table: String,
pub preference: u16,
pub input_filter: String,
pub output_filter: String,
pub route_stats: Option<RouteStats>,
pub bgp_next_hop: Option<String>,
}
impl Channel {
fn from_lines(lines: &[&str]) -> Option<Channel> {
let base_indent = indent_level(lines[0]);
if let Some(name) = lines[0][base_indent..].strip_prefix("Channel ") {
let mut state: Option<&str> = None;
let mut table: Option<&str> = None;
let mut preference = 0_u16;
let mut input_filter: Option<&str> = None;
let mut output_filter: Option<&str> = None;
let mut bgp_next_hop: Option<&str> = None;
let mut rcs_recvd_pos = 0_usize;
let mut rcs_rejct_pos = 0_usize;
let mut rcs_filtr_pos = 0_usize;
let mut rcs_ignrd_pos = 0_usize;
let mut rcs_accpt_pos = 0_usize;
let mut route_stats = RouteStats::default();
let mut has_route_stats = false;
for line in &lines[1..] {
if let Some(cpos) = line.find(':') {
let key = line[..cpos].trim();
let val = line[(cpos + 1)..].trim();
match key {
"State" => state = Some(val),
"Table" => table = Some(val),
"Preference" => preference = val.parse().unwrap_or(0),
"Input filter" => input_filter = Some(val),
"Output filter" => output_filter = Some(val),
"Routes" => {
has_route_stats = true;
for pair in val.split(',') {
if let Some(u) = pair.strip_suffix("imported") {
route_stats.imported = u.trim().parse().unwrap_or(0);
} else if let Some(u) = pair.strip_suffix("exported") {
route_stats.exported = u.trim().parse().unwrap_or(0);
} else if let Some(u) = pair.strip_suffix("preferred") {
route_stats.preferred = u.trim().parse().unwrap_or(0);
} else if let Some(u) = pair.strip_suffix("filtered") {
route_stats.filtered = u.trim().parse().unwrap_or(0);
}
}
}
"Route change stats" => {
has_route_stats = true;
for (rcs_idx, it) in val.split_ascii_whitespace().enumerate() {
match it {
"received" => rcs_recvd_pos = rcs_idx,
"rejected" => rcs_rejct_pos = rcs_idx,
"filtered" => rcs_filtr_pos = rcs_idx,
"ignored" => rcs_ignrd_pos = rcs_idx,
"accepted" => rcs_accpt_pos = rcs_idx,
_ => {}
}
}
}
"Import updates" => {
route_stats.import_updates = rcs_from_line(
val,
rcs_recvd_pos,
rcs_rejct_pos,
rcs_filtr_pos,
rcs_ignrd_pos,
rcs_accpt_pos,
)
}
"Import withdraws" => {
route_stats.import_withdraws = rcs_from_line(
val,
rcs_recvd_pos,
rcs_rejct_pos,
rcs_filtr_pos,
rcs_ignrd_pos,
rcs_accpt_pos,
)
}
"Export updates" => {
route_stats.export_updates = rcs_from_line(
val,
rcs_recvd_pos,
rcs_rejct_pos,
rcs_filtr_pos,
rcs_ignrd_pos,
rcs_accpt_pos,
)
}
"Export withdraws" => {
route_stats.export_withdraws = rcs_from_line(
val,
rcs_recvd_pos,
rcs_rejct_pos,
rcs_filtr_pos,
rcs_ignrd_pos,
rcs_accpt_pos,
)
}
"BGP Next hop" => bgp_next_hop = Some(val),
_ => {}
}
}
}
Some(Channel {
name: name.to_owned(),
state: state.unwrap_or("unknown").to_owned(),
table: table.unwrap_or("unknown").to_owned(),
preference,
input_filter: input_filter.unwrap_or("unknown").to_owned(),
output_filter: output_filter.unwrap_or("unknown").to_owned(),
route_stats: if has_route_stats {
Some(route_stats)
} else {
None
},
bgp_next_hop: bgp_next_hop.map(|x| x.to_owned()),
})
} else {
None
}
}
}
#[derive(Debug, Default)]
pub struct RouteStats {
pub imported: u32,
pub exported: u32,
pub preferred: u32,
pub filtered: u32,
pub import_updates: RouteChangeStats,
pub import_withdraws: RouteChangeStats,
pub export_updates: RouteChangeStats,
pub export_withdraws: RouteChangeStats,
}
#[derive(Debug, Default)]
pub struct RouteChangeStats {
pub received: u32,
pub rejected: u32,
pub filtered: u32,
pub ignored: u32,
pub accepted: u32,
}
#[derive(Debug)]
pub struct BgpInfo {
pub state: String,
pub neighbor: BgpNeighbor,
pub neighbor_as: u32,
pub local_as: u32,
pub graceful_restart_active: bool,
pub session: Option<BgpSession>,
}
impl BgpInfo {
fn from_lines(lines: &[&str]) -> Option<BgpInfo> {
let mut state: Option<&str> = None;
let mut neighbor: Option<BgpNeighbor> = None;
let mut neighbor_as = 0_u32;
let mut local_as = 0_u32;
let mut neighbor_id: Option<&str> = None;
let mut attributes: Vec<String> = vec![];
let mut source_address: Option<&str> = None;
let mut hold_time = 0_u64;
let mut hold_time_remaining = 0_f64;
let mut keepalive_time = 0_u64;
let mut keepalive_time_remaining = 0_f64;
for (key, val) in lines.iter().map(|x| {
if let Some(pos) = x.find(':') {
(x[..pos].trim(), x[(pos + 1)..].trim())
} else {
("", "")
}
}) {
match key {
"BGP state" => state = Some(val),
"Neighbor address" => neighbor = Some(BgpNeighbor::Address(val.to_owned())),
"Neighbor range" => neighbor = Some(BgpNeighbor::Range(val.to_owned())),
"Neighbor AS" => neighbor_as = val.parse().unwrap_or(0),
"Local AS" => local_as = val.parse().unwrap_or(0),
"Neighbor ID" => neighbor_id = Some(val),
"Session" => {
attributes = val.split_ascii_whitespace().map(|x| x.to_owned()).collect()
}
"Source address" => source_address = Some(val),
"Hold timer" => {
if let Some(pos) = val.find('/') {
hold_time_remaining = val[..pos].parse().unwrap_or(0.0);
hold_time = val[(pos + 1)..].parse().unwrap_or(0);
}
}
"Keepalive timer" => {
if let Some(pos) = val.find('/') {
keepalive_time_remaining = val[..pos].parse().unwrap_or(0.0);
keepalive_time = val[(pos + 1)..].parse().unwrap_or(0);
}
}
_ => {}
}
}
if let Some(state) = state {
neighbor.map(|neighbor| BgpInfo {
state: state.to_owned(),
neighbor,
neighbor_as,
local_as,
graceful_restart_active: lines
.iter()
.any(|x| x.contains("Neighbor graceful restart active")),
session: neighbor_id.map(|nid| BgpSession {
neighbor_id: nid.to_owned(),
attributes,
source_address: source_address.unwrap_or("").to_owned(),
hold_time,
hold_time_remaining,
keepalive_time,
keepalive_time_remaining,
}),
})
} else {
None
}
}
}
#[derive(Debug)]
pub enum BgpNeighbor {
Address(String),
Range(String),
}
#[derive(Debug)]
pub struct BgpSession {
pub neighbor_id: String,
pub attributes: Vec<String>,
pub source_address: String,
pub hold_time: u64,
pub hold_time_remaining: f64,
pub keepalive_time: u64,
pub keepalive_time_remaining: f64,
}
#[inline]
fn filler_to_option(s: &str) -> Option<String> {
if s.trim_matches('-').is_empty() {
None
} else {
Some(s.to_owned())
}
}
#[inline]
fn indent_level(line: &str) -> usize {
line.find(|c: char| !c.is_ascii_whitespace()).unwrap_or(0)
}
#[inline]
fn rcs_from_line(
line: &str,
recvd_pos: usize,
rejct_pos: usize,
filtrd_pos: usize,
ignrd_pos: usize,
accpt_pos: usize,
) -> RouteChangeStats {
let mut idx = 0;
let mut rcs = RouteChangeStats {
received: 0,
rejected: 0,
filtered: 0,
ignored: 0,
accepted: 0,
};
for v in line.split_ascii_whitespace() {
if v.contains('-') {
idx += 1;
} else {
let u = v.parse::<u32>().unwrap_or(0);
if idx == recvd_pos {
rcs.received = u;
} else if idx == rejct_pos {
rcs.rejected = u;
} else if idx == filtrd_pos {
rcs.filtered = u;
} else if idx == ignrd_pos {
rcs.ignored = u;
} else if idx == accpt_pos {
rcs.accepted = u;
}
idx += 1;
}
}
rcs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::*;
#[test]
fn test_protocol_parse() {
let _ = env_logger::try_init();
let content = "device1 Device --- up 2022-04-14
direct_eth0 Direct --- up 2022-04-14
kernel_v4 Kernel master4 up 2022-04-14
kernel_v6 Kernel master6 up 2022-04-14
bfd1 BFD --- up 2022-04-14
bgp_local4 BGP --- up 2022-04-16 Established
bgp_local6 BGP --- up 2022-04-16 Established
pipe6_kernel_main Pipe --- up 2025-10-17 table6_kernel_main <=> table6_meadow
pipe6_kernel_default Pipe --- up 2025-10-17 08:18:58 table6_kernel_default <=> table6_meadow"
.lines()
.map(|x| x.trim_start())
.collect::<Vec<&str>>()
.join("\n");
let protocol =
Protocol::from_enum(&Message::ProtocolList(content)).expect("failed to parse");
assert_eq!(protocol.len(), 9);
assert_eq!(protocol[0].name, "device1");
assert_eq!(protocol[0].proto, "Device");
assert!(protocol[0].table.is_none());
assert_eq!(protocol[0].state, "up");
assert_eq!(protocol[0].since, "2022-04-14");
assert!(protocol[0].info.is_none());
assert_eq!(protocol[1].name, "direct_eth0");
assert_eq!(protocol[1].proto, "Direct");
assert!(protocol[1].table.is_none());
assert_eq!(protocol[1].state, "up");
assert_eq!(protocol[1].since, "2022-04-14");
assert!(protocol[1].info.is_none());
assert_eq!(protocol[2].name, "kernel_v4");
assert_eq!(protocol[2].proto, "Kernel");
assert_eq!(protocol[2].table.as_ref().unwrap(), "master4");
assert_eq!(protocol[2].state, "up");
assert_eq!(protocol[2].since, "2022-04-14");
assert!(protocol[2].info.is_none());
assert_eq!(protocol[3].name, "kernel_v6");
assert_eq!(protocol[3].proto, "Kernel");
assert_eq!(protocol[3].table.as_ref().unwrap(), "master6");
assert_eq!(protocol[3].state, "up");
assert_eq!(protocol[3].since, "2022-04-14");
assert!(protocol[3].info.is_none());
assert_eq!(protocol[4].name, "bfd1");
assert_eq!(protocol[4].proto, "BFD");
assert!(protocol[4].table.is_none());
assert_eq!(protocol[4].state, "up");
assert_eq!(protocol[4].since, "2022-04-14");
assert!(protocol[4].info.is_none());
assert_eq!(protocol[5].name, "bgp_local4");
assert_eq!(protocol[5].proto, "BGP");
assert!(protocol[5].table.is_none());
assert_eq!(protocol[5].state, "up");
assert_eq!(protocol[5].since, "2022-04-16");
assert_eq!(protocol[5].info.as_ref().unwrap(), "Established");
assert_eq!(protocol[6].name, "bgp_local6");
assert_eq!(protocol[6].proto, "BGP");
assert!(protocol[6].table.is_none());
assert_eq!(protocol[6].state, "up");
assert_eq!(protocol[6].since, "2022-04-16");
assert_eq!(protocol[6].info.as_ref().unwrap(), "Established");
assert_eq!(protocol[7].name, "pipe6_kernel_main");
assert_eq!(protocol[7].proto, "Pipe");
assert!(protocol[7].table.is_none());
assert_eq!(protocol[7].state, "up");
assert_eq!(protocol[7].since, "2025-10-17");
assert_eq!(
protocol[7].info.as_ref().unwrap(),
"table6_kernel_main <=> table6_meadow"
);
assert_eq!(protocol[8].name, "pipe6_kernel_default");
assert_eq!(protocol[8].proto, "Pipe");
assert!(protocol[8].table.is_none());
assert_eq!(protocol[8].state, "up");
assert_eq!(protocol[8].since, "2025-10-17 08:18:58");
assert_eq!(
protocol[8].info.as_ref().unwrap(),
"table6_kernel_default <=> table6_meadow"
);
}
#[test]
fn test_bgp_info_parse() {
let _ = env_logger::try_init();
let lines = "
BGP state: Established
Neighbor address: 172.29.0.1
Neighbor AS: 64561
Local AS: 64560
Neighbor ID: 172.29.0.1
Local capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Neighbor capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
Restart time: 10
AF supported: ipv4
AF preserved: ipv4
4-octet AS numbers
Long-lived graceful restart
Session: external AS4
Source address: 172.29.0.12
Hold timer: 45.058/90
Keepalive timer: 25.940/30"
.lines()
.filter(|x| !x.trim().is_empty())
.collect::<Vec<&str>>();
let bgp_info = BgpInfo::from_lines(&lines).expect("failed to parse bgp info");
assert_eq!(bgp_info.state, "Established");
assert!(matches!(bgp_info.neighbor, BgpNeighbor::Address(x) if x == "172.29.0.1"));
assert_eq!(bgp_info.neighbor_as, 64561);
assert_eq!(bgp_info.local_as, 64560);
let session = bgp_info
.session
.expect("expected bgp session to be defined");
assert_eq!(session.neighbor_id, "172.29.0.1");
assert_eq!(session.attributes, vec!["external", "AS4"]);
assert_eq!(session.source_address, "172.29.0.12");
assert_eq!(session.hold_time_remaining, 45.058);
assert_eq!(session.hold_time, 90);
assert_eq!(session.keepalive_time_remaining, 25.940);
assert_eq!(session.keepalive_time, 30);
}
#[test]
fn test_channel_parse() {
let _ = env_logger::try_init();
let lines = "
Channel ipv4
State: UP
Table: master4
Preference: 100
Input filter: ACCEPT
Output filter: (unnamed)
Routes: 1 imported, 10 exported, 1 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 7 1 2 3 1
Import withdraws: 2 0 --- 0 2
Export updates: 12 1 1 --- 10
Export withdraws: 4 --- --- --- 4
BGP Next hop: 172.29.0.1"
.lines()
.filter(|x| !x.trim().is_empty())
.collect::<Vec<&str>>();
let channel = Channel::from_lines(&lines).expect("failed to parse bgp info");
assert_eq!(channel.name, "ipv4");
assert_eq!(channel.table, "master4");
assert_eq!(channel.preference, 100);
assert_eq!(channel.input_filter, "ACCEPT");
assert_eq!(channel.output_filter, "(unnamed)");
let route_stats = &channel.route_stats.expect("route stats not defined");
assert_eq!(route_stats.imported, 1);
assert_eq!(route_stats.exported, 10);
assert_eq!(route_stats.preferred, 1);
assert_eq!(route_stats.filtered, 0);
assert_eq!(route_stats.import_updates.received, 7);
assert_eq!(route_stats.import_updates.rejected, 1);
assert_eq!(route_stats.import_updates.filtered, 2);
assert_eq!(route_stats.import_updates.ignored, 3);
assert_eq!(route_stats.import_updates.accepted, 1);
assert_eq!(route_stats.import_withdraws.received, 2);
assert_eq!(route_stats.import_withdraws.rejected, 0);
assert_eq!(route_stats.import_withdraws.ignored, 0);
assert_eq!(route_stats.import_withdraws.accepted, 2);
assert_eq!(route_stats.export_updates.received, 12);
assert_eq!(route_stats.export_updates.rejected, 1);
assert_eq!(route_stats.export_updates.filtered, 1);
assert_eq!(route_stats.export_updates.accepted, 10);
assert_eq!(route_stats.export_withdraws.received, 4);
assert_eq!(route_stats.export_withdraws.accepted, 4);
}
#[test]
fn test_proto_detail_parse() {
let lines = "
Description: BGP with internal router
BGP state: Established
Neighbor address: 172.29.0.1
Neighbor AS: 64561
Local AS: 64560
Neighbor ID: 172.29.0.1
Local capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
4-octet AS numbers
Enhanced refresh
Long-lived graceful restart
Neighbor capabilities
Multiprotocol
AF announced: ipv4
Route refresh
Graceful restart
Restart time: 10
AF supported: ipv4
AF preserved: ipv4
4-octet AS numbers
Long-lived graceful restart
Session: external AS4
Source address: 172.29.0.12
Hold timer: 45.058/90
Keepalive timer: 25.940/30
Channel ipv4
State: UP
Table: master4
Preference: 100
Input filter: ACCEPT
Output filter: (unnamed)
Routes: 1 imported, 10 exported, 1 preferred
Route change stats: received rejected filtered ignored accepted
Import updates: 7 1 2 3 1
Import withdraws: 2 0 --- 0 2
Export updates: 12 1 1 --- 10
Export withdraws: 4 --- --- --- 4
BGP Next hop: 172.29.0.1"
.lines()
.filter(|x| !x.trim().is_empty())
.collect::<Vec<&str>>();
let base_indent = indent_level(lines[0]);
let content = lines
.iter()
.map(|x| &x[(base_indent - 2)..])
.collect::<Vec<&str>>()
.join("\n");
let pd = ProtocolDetail::from_enum(&Message::ProtocolDetails(content))
.expect("parsing for protocol details failed");
assert!(matches!(pd.description, Some(x) if x == "BGP with internal router"));
assert!(pd.message.is_none());
assert!(pd.router_id.is_none());
assert!(pd.vrf.is_none());
if let Some(ProtoSpecificInfo::Bgp(bgp_info)) = pd.proto_info {
assert_eq!(bgp_info.state, "Established");
assert!(matches!(bgp_info.neighbor, BgpNeighbor::Address(x) if x == "172.29.0.1"));
assert_eq!(bgp_info.neighbor_as, 64561);
assert_eq!(bgp_info.local_as, 64560);
let session = bgp_info
.session
.expect("expected bgp session to be defined");
assert_eq!(session.neighbor_id, "172.29.0.1");
assert_eq!(session.attributes, vec!["external", "AS4"]);
assert_eq!(session.source_address, "172.29.0.12");
assert_eq!(session.hold_time_remaining, 45.058);
assert_eq!(session.hold_time, 90);
assert_eq!(session.keepalive_time_remaining, 25.940);
assert_eq!(session.keepalive_time, 30);
} else {
panic!("bgp info parsing failed");
}
assert_eq!(pd.channels.len(), 1);
let channel = &pd.channels[0];
assert_eq!(channel.name, "ipv4");
assert_eq!(channel.table, "master4");
assert_eq!(channel.preference, 100);
assert_eq!(channel.input_filter, "ACCEPT");
assert_eq!(channel.output_filter, "(unnamed)");
let route_stats = channel
.route_stats
.as_ref()
.expect("route stats not defined");
assert_eq!(route_stats.imported, 1);
assert_eq!(route_stats.exported, 10);
assert_eq!(route_stats.preferred, 1);
assert_eq!(route_stats.filtered, 0);
assert_eq!(route_stats.import_updates.received, 7);
assert_eq!(route_stats.import_updates.rejected, 1);
assert_eq!(route_stats.import_updates.filtered, 2);
assert_eq!(route_stats.import_updates.ignored, 3);
assert_eq!(route_stats.import_updates.accepted, 1);
assert_eq!(route_stats.import_withdraws.received, 2);
assert_eq!(route_stats.import_withdraws.rejected, 0);
assert_eq!(route_stats.import_withdraws.ignored, 0);
assert_eq!(route_stats.import_withdraws.accepted, 2);
assert_eq!(route_stats.export_updates.received, 12);
assert_eq!(route_stats.export_updates.rejected, 1);
assert_eq!(route_stats.export_updates.filtered, 1);
assert_eq!(route_stats.export_updates.accepted, 10);
assert_eq!(route_stats.export_withdraws.received, 4);
assert_eq!(route_stats.export_withdraws.accepted, 4);
}
}