bgpkit-parser 0.23.0

MRT/BGP/BMP data processing library
Documentation
/// `ris_message`: Message from a particular RIS Route Collector
///
/// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message.schema.json>
use crate::models::*;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::net::IpAddr;

#[derive(Debug, Serialize, Deserialize)]
pub struct RisMessage {
    pub timestamp: f64,
    #[serde(with = "as_str")]
    pub peer: IpAddr,
    #[serde(with = "as_str")]
    pub peer_asn: Asn,
    pub id: String,
    pub raw: Option<String>,
    pub host: String,
    #[serde(rename = "type", flatten)]
    pub msg: Option<RisMessageEnum>,
}

/// `ris_message`: Message from a particular RIS Route Collector
///
/// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message.schema.json>
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RisMessageEnum {
    /// Update message
    ///
    /// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-UPDATE.schema.json>
    UPDATE {
        path: Option<AsPath>,
        community: Option<Vec<(u32, u16)>>,
        origin: Option<String>,
        med: Option<u32>,
        aggregator: Option<String>,
        announcements: Option<Vec<Announcement>>,
        withdrawals: Option<Vec<String>>,
    },
    /// KeepAlive message
    ///
    /// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-KEEPALIVE.schema.json>
    KEEPALIVE {},
    /// Open message
    ///
    /// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-OPEN.schema.json>
    OPEN {
        direction: String,
        version: u8,
        asn: u32,
        hold_time: u32,
        router_id: String,
        capabilities: Value,
    },
    /// Notification message
    ///
    /// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-NOTIFICATION.schema.json>
    NOTIFICATION { notification: Notification },
    /// RIS Peer State message
    ///
    /// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-RIS_PEER_STATE.schema.json>
    ///
    /// The live stream sends this message with `"type":"STATE"`, not `RIS_PEER_STATE` as documented
    /// in the documentation and schema.
    ///
    /// Serialise to `STATE` and accept `RIS_PEER_STATE` when deserialising.
    ///
    /// TODO: re-validate the documentation state on/after January 2027
    #[serde(rename = "STATE", alias = "RIS_PEER_STATE")]
    RIS_PEER_STATE { state: String },
}

/// Notification message content
///
/// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-NOTIFICATION.schema.json>
#[derive(Debug, Serialize, Deserialize)]
pub struct Notification {
    pub code: u32,
    pub subcode: u32,
    pub data: Option<String>,
}

/// Update message announcement content
///
/// Schema: <https://ris-live.ripe.net/schemas/v1/ris_message-UPDATE.schema.json>
///
/// RIS Live projects the MP_REACH_NLRI next-hop field verbatim, so an RFC 2545
/// global + link-local pair arrives as one comma-joined string
/// (`"next_hop": "2001:db8::1,fe80::1"`). The value is kept verbatim, like
/// `prefixes`, so deserialisation cannot fail on it and re-serialisation is
/// byte-for-byte. Parse it with [`NextHopAddress`]'s `FromStr` and resolve
/// pairs via [`NextHopAddress::global_addr`], as
/// [`parse_ris_live_message`](crate::parser::rislive::parse_ris_live_message)
/// does.
#[derive(Debug, Serialize, Deserialize)]
pub struct Announcement {
    /// Next hop as projected by RIS Live: one address, or a comma-joined
    /// global + link-local pair (RFC 2545).
    pub next_hop: String,
    pub prefixes: Vec<String>,
}

mod as_str {
    use serde::de::Error;
    use serde::{Deserialize, Deserializer, Serializer};
    use std::borrow::Cow;
    use std::fmt::Display;
    use std::str::FromStr;

    pub fn serialize<S, T>(this: &T, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
        T: Display,
    {
        serializer.collect_str(this)
    }

    pub fn deserialize<'de, D, T>(deserializer: D) -> Result<T, D::Error>
    where
        D: Deserializer<'de>,
        T: FromStr,
        <T as FromStr>::Err: Display,
    {
        let str_repr = <Cow<'de, str>>::deserialize(deserializer)?;
        str_repr.parse().map_err(D::Error::custom)
    }
}

#[cfg(test)]
mod tests {
    use crate::models::NextHopAddress;
    use crate::parser::rislive::messages::ris_message::{Announcement, RisMessage};
    use crate::rislive::messages::{RisLiveMessage, RisMessageEnum};
    use std::net::IpAddr;

    #[test]
    fn test_deserialize_update() {
        let message = r#"
            {"timestamp":1568279796.63,"peer":"192.0.2.0","peer_asn":"64496","id":"21-192-0-2-0-11188782","host":"rrc21","type":"UPDATE","path":[1,2,3,4],"announcements":[{"next_hop":"192.0.2.0","prefixes":["192.0.3.0/24"]}]}
        "#;
        let _msg: RisMessage = serde_json::from_str(message).unwrap();
    }

    #[test]
    fn test_keep_alive_msg() {
        let msg_str = r#"{"timestamp":1568284616.24,"peer":"192.0.2.0","peer_asn":"64496","id":"21-192-0-2-0-53776312","host":"rrc00","type":"KEEPALIVE"}
"#;
        let _msg: RisMessage = serde_json::from_str(msg_str).unwrap();
    }

    #[test]
    fn test_open_msg() {
        let msg_str = r#"
        {"timestamp":1568365292.84,"peer":"192.0.2.1","peer_asn":"64496","id":"00-192-0-2-0-180513","host":"rrc00","type":"OPEN","direction":"sent","version":4,"asn":65536,"hold_time":180,"router_id":"192.0.3.1","capabilities":{"1":{"name":"multiprotocol","families":["ipv4/unicast","ipv6/unicast"]},"65":{"name":"asn4","asn4":65536}}}
"#;
        let _msg: RisMessage = serde_json::from_str(msg_str).unwrap();
    }

    #[test]
    fn test_notification_msg() {
        let msg_str = r#"
        {"timestamp":1568365292.84,"peer":"192.0.2.1","peer_asn":"64496","id":"00-192-0-2-0-180513","host":"rrc00","type":"NOTIFICATION","notification":{"code":6,"subcode":7,"data":"0605"}}
"#;
        let _msg: RisMessage = serde_json::from_str(msg_str).unwrap();
    }

    #[test]
    fn test_peer_state_change_msg() {
        let msg_str = r#"
        {"timestamp":1568365292.84,"peer":"192.0.2.1","peer_asn":"64496","id":"00-192-0-2-0-180513","host":"rrc00","type":"RIS_PEER_STATE","state":"connected"}
"#;
        let _msg: RisMessage = serde_json::from_str(msg_str).unwrap();
    }

    #[test]
    fn test_peer_state_change_msg_state_alias() {
        // The live stream sends "type":"STATE" instead of "RIS_PEER_STATE"
        let fixtures = [
            r#"{"type":"ris_message","data":{"timestamp":1788948301.310,"peer":"2001:7f8:13::a503:4927:1","peer_asn":"34927","id":"2001:7f8:13::a503:4927:1-01a085a0c5fe0002","host":"rrc03.ripe.net","type":"STATE","state":"connected"}}"#,
            r#"{"type":"ris_message","data":{"timestamp":1788948302.310,"peer":"2001:7f8:13::a503:4927:1","peer_asn":"34927","id":"2001:7f8:13::a503:4927:1-01a085a0c9e60009","host":"rrc03.ripe.net","type":"STATE","state":"down"}}"#,
        ];
        for (fixture, expected_state) in fixtures.iter().zip(["connected", "down"]) {
            let msg: RisLiveMessage = serde_json::from_str(fixture).unwrap();
            let RisLiveMessage::RisMessage(msg) = msg else {
                panic!("incorrect message type");
            };
            match &msg.msg {
                Some(RisMessageEnum::RIS_PEER_STATE { state }) => {
                    assert_eq!(state, expected_state);
                }
                other => panic!("incorrect message type: {:?}", other),
            }
            // serializes back as "STATE", not "RIS_PEER_STATE"
            let serialized = serde_json::to_string(&msg).unwrap();
            assert!(serialized.contains(r#""type":"STATE""#));
            assert!(!serialized.contains("RIS_PEER_STATE"));
        }
    }

    #[test]
    fn test_deserialize_update_with_link_local_next_hop() {
        // A v6 IXP peer that includes a link-local address in MP_REACH_NLRI:
        // RIS Live comma-joins both addresses into one `next_hop` string.
        let msg_str = r#"{"type":"ris_message","data":{"timestamp":1789019601.670,"peer":"2001:7f8:4::3:2be1:1","peer_asn":"207841","id":"2001:7f8:4::3:2be1:1-01a089e0bb060006","host":"rrc01.ripe.net","type":"UPDATE","path":[207841,6939],"community":[],"origin":"IGP","announcements":[{"next_hop":"2001:7f8:4::3:2be1:1,fe80::3efd:feff:feee:62ca","prefixes":["2a12:3fc6::/48"]}],"withdrawals":[]}}"#;

        let RisLiveMessage::RisMessage(msg) = serde_json::from_str(msg_str).unwrap() else {
            panic!("incorrect message type");
        };
        // The whole message must survive: the flattened `Option` turns any
        // body deserialisation error into a silently typeless message.
        let Some(RisMessageEnum::UPDATE { announcements, .. }) = msg.msg else {
            panic!("expected an UPDATE, got {:?}", msg.msg);
        };

        let announcements = announcements.unwrap();
        assert_eq!(announcements.len(), 1);
        assert_eq!(
            announcements[0].next_hop,
            "2001:7f8:4::3:2be1:1,fe80::3efd:feff:feee:62ca"
        );
        assert_eq!(announcements[0].prefixes, vec!["2a12:3fc6::/48"]);

        // the comma-joined pair parses into a scope-resolvable next hop
        let next_hop: NextHopAddress = announcements[0].next_hop.parse().unwrap();
        assert_eq!(
            next_hop.global_addr(),
            "2001:7f8:4::3:2be1:1".parse::<IpAddr>().unwrap()
        );
        assert_eq!(
            next_hop.link_local_addr(),
            Some("fe80::3efd:feff:feee:62ca".parse().unwrap())
        );
    }

    #[test]
    fn test_announcement_round_trip() {
        // verbatim `next_hop`: every form, including a reversed pair,
        // re-serialises byte-for-byte
        for next_hop in [
            "2001:db8::1,fe80::1",
            "fe80::1,2001:db8::1",
            "2001:db8::1",
            "fe80::1",
            "192.0.2.1",
        ] {
            let json = format!(r#"{{"next_hop":"{next_hop}","prefixes":["2001:db8::/32"]}}"#);
            let announcement: Announcement = serde_json::from_str(&json).unwrap();
            assert_eq!(serde_json::to_string(&announcement).unwrap(), json);
        }
    }

    #[test]
    fn test_withdrawals() {
        let msg_str = r#"{ "type": "ris_message", "data": { "timestamp": 1740561857.910, "peer": "2606:6dc0:1301::1", "peer_asn": "13781", "id": "2606:6dc0:1301::1-019541923d760008", "host": "rrc25.ripe.net", "type": "UPDATE", "path": [], "community": [], "announcements": [], "withdrawals": [ "2605:de00:bb:0:0:0:0:0/48" ] } }"#;
        let msg: RisLiveMessage = serde_json::from_str(msg_str).unwrap();
        if let RisLiveMessage::RisMessage(msg) = msg {
            assert!(msg.msg.is_some());
            assert!(matches!(msg.msg, Some(RisMessageEnum::UPDATE { .. })));
            match msg.msg.unwrap() {
                RisMessageEnum::UPDATE { withdrawals, .. } => {
                    assert!(withdrawals.is_some());
                    let withdrawals = withdrawals.unwrap();
                    assert_eq!(withdrawals.len(), 1);
                    assert_eq!(withdrawals[0], "2605:de00:bb:0:0:0:0:0/48");
                }
                _ => {
                    panic!("incorrect message type")
                }
            }
        } else {
            panic!("incorrect message type");
        }
    }
}