rotonda 0.6.0

composable, programmable BGP engine
Documentation
// Prepare lists of ASNs or prefixes to match against in filters.
//
// The lists constructed in this function are available in all the filters, but
// compiling the lists only happens once.


// Define a single Asn or a List of Asns:
const MY_ASN: Asn =  AS211321;
const MY_ASNS: List[Asn] = [AS1, AS2, AS3];


// A list of `AsnRange`'s, for all defined bogons.
// For larger ranges, this type is preferred over constructing a `List[Asn]`.
const BOGON_ASNS: List[AsnRange] = [
    AsnRange.single(AS0),            // RFC 7607
    AsnRange.single(AS23456),        // RFC 4893 AS_TRANS
    AsnRange.single(AS65535),        // RFC 7300 Last 16 bit ASN
    AsnRange.single(AS4294967295),   // RFC 7300 Last 32 bit ASN

    // RFC 5398 and documentation/example ASNs
    AsnRange.new(AS64496, AS64511),

    // RFC 6996 Private ASNs
    AsnRange.new(AS64512, AS65534),

    // RFC 5398 and documentation/example ASNs
    AsnRange.new(AS65536, AS65551),

    // RFC IANA reserved ASNs
    AsnRange.new(AS65552, AS131071),

    // RFC 6996 Private ASNs
    AsnRange.new(AS4200000000, AS4294967294),
];



// Create a list containing specific prefixes.
fn compile_prefix_lists() -> List[Prefix] {
    let list = List.new();
    list.push(1.2.3.0/24);
    list
}

// Another way to create a list of prefixes.
const MY_PREFIXES: List[Prefix] = [
    185.49.140.0/23,
    2a04:b900::/29,
];


// Bogon prefixes.
const BOGONS: List[Prefix] = [
    // source: https://bgpfilterguide.nlnog.net/guides/bogon_prefixes/
    // retrieved April 2026
    0.0.0.0/8,          //	RFC 1122 ‘this’ network
    10.0.0.0/8,         //	RFC 1918 private space
    100.64.0.0/10,      //	RFC 6598 Carrier grade nat space
    127.0.0.0/8,        //	RFC 1122 localhost
    169.254.0.0/16,     //	RFC 3927 link local
    172.16.0.0/12,      //	RFC 1918 private space
    192.0.2.0/24,       //	RFC 5737 TEST-NET-1
    192.88.99.0/24,     //	RFC 7526 6to4 anycast relay
    192.168.0.0/16,     //	RFC 1918 private space
    198.18.0.0/15,      //	RFC 2544 benchmarking
    198.51.100.0/24,    //	RFC 5737 TEST-NET-2
    203.0.113.0/24,     //	RFC 5737 TEST-NET-3
    224.0.0.0/4,        //	multicast
    240.0.0.0/4,        //	reserved
    0100::/64,          //	RFC 6666 Discard-Only
    2001:2::/48,        //	RFC 5180 BMWG
    2001:10::/28,       //	RFC 4843 ORCHID
    2001:db8::/32,      //	RFC 3849 documentation
    2002::/16,          //	RFC 7526 6to4 anycast relay
    3ffe::/16,          //	RFC 3701 old 6bone
    3fff::/20,          //	RFC 9637 documentation
    5f00::/16,          //	RFC 9602 SRv6 SIDs
    fc00::/7,           //	RFC 4193 unique local unicast
    fe80::/10,          //	RFC 4291 link local unicast
    fec0::/10,          //	RFC 3879 old site local unicast
    ff00::/8,           //	RFC 4291 multicast
];





// The bgp_in filter works on incoming BGP UPDATE messages.
//
// One such message can contain multiple NLRI, thus multiple announcements or
// withdrawals. To act on individual announcements or withdrawals, use the
// 'rib-in' filter-map below.
filter bgp_in(
    bgp_msg: BgpMsg,
    ingress_info: IngressInfo,
) {

    let origin_to_log = AS65536;
    let community_to_log = Community.NO_PEER;

    if aspath_origin_matches(bgp_msg.aspath(), origin_to_log) {
        output.log_matched_origin(origin_to_log);
    }

    match bgp_msg.communities() {
        Some(communities) => {
            if communities.contains(community_to_log) {
                output.log_matched_community(community_to_log)
            }
        }
        None => { }
    }

    accept
}


// The bmp_in filter works on incoming BMP messages.
//
// While most BMP message will be of type RouteMonitoring (transporting route
// information via an encapsulated BGP UPDATE message), this filter-map can act
// on different types as well. Helper methods are provided, e.g.
// 'is_peer_down()' returns true if the message is a BMP PeerDownNotification.
filter bmp_in(
    bmp_msg: BmpMsg,
    ingress_info: IngressInfo,
) {
    let asn_to_log = AS65536;
    let community_to_log = Community.NO_PEER;

    if bmp_msg.is_peer_down() {
        output.log_peer_down()
    }

    // Keep track of a specific deprecated (and possibly problematic) attribute.
    // This will show up in the /metrics endpoint as
    //
    //   'roto_user_defined_unwanted_attribute'
    //
    if bmp_msg.has_attribute(28) {
        metrics.increase_counter(
            f"unwanted_attribute{{peer_asn=\"{ingress_info.peer_asn()}\"}}",
            1
        );
    }

    if bmp_msg.is_ibgp(MY_ASN) {
        reject
    }

    if aspath_contains(bmp_msg.aspath(), asn_to_log) {
        output.log_matched_asn(asn_to_log);
    }

    match bmp_msg.aspath() {
        Some(aspath) => {
            if aspath.contains(asn_to_log) {
                output.log_matched_asn(asn_to_log);
            }
        }
        None => { }
    }

    accept
}

// The rib_in_pre filter processes individual routes prior to insertion into the
// main RIB.
//
// Different from the BGP UPDATE message in the bgp-in filter-map, and the BMP
// RouteMonitoring message in the bmp-in filter-map, the rib-in filter works on
// individual announcements and withdrawals, typed Route.
//
// This enables for fine-grained, per announcement filtering and logging, and
// allows comparing of and acting on the actual NLRI (most often, the prefix).
//
// Filtering purely based on values of attributes (regardless of the individual
// NLRI) can and should be done in the bmp-in/bgp-in filter-maps, as making such
// a decision early on is more efficient.
filter rib_in_pre(
    route: Route,
    ingress_info: IngressInfo,
) {

    // Example of calling external programs via `command()`:
    //match route.aspath() {
    //    Some(aspath) => {
    //        if aspath.contains(AS1133) {
    //            command("/usr/bin/handle_route.py", [
    //                    "--route", route.fmt_json(),
    //            ]);
    //        }
    //    }
    //    None => { }
    //}

    if MY_PREFIXES.contains(route.prefix()) {
        output.log_prefix(route.prefix());
    }

    // Only has effect when an RTR unit is configured in rotonda.conf
    rpki.check_rov(route);

    accept
}


// The vrp_update filter processes updates pertaining to VRPs coming in via RTR.
//
// This is mainly useful for monitoring and generally, one would like to always
// return 'accept' from this filter: Returning 'reject' will cause Rotonda to not
// apply the update which is probably not what you want. As such, if this filter
// definition is omitted, Rotonda defaults to 'accept'.
//
// Note that this filter is triggered for any incoming VRP update, regardless of
// whether the prefixes in the VRPs are stored in the RIB. To act on actual
// changes in terms of ROV status of stored prefixes, see the
// `rib_in_rov_status_update` function below.
//
// NB: this function is only called for RTR Serial updates, not for the initial
// RTR Cache Reset sync.
filter vrp_update(vrp_update: VrpUpdate) {

    if MY_ASNS.contains(vrp_update.asn()) {
        output.entry().timestamped_custom(f"[RTR] (my_asns): {vrp_update}");
        output.write_entry();
    }

    if vrp_update.asn().appears_on(BOGON_ASNS) {
        output.entry().timestamped_custom(f"[RTR] (bogon): {vrp_update}");
        output.write_entry();
    }

    if vrp_update.prefix().covered_by(MY_PREFIXES) {
        output.timestamped_print(f"[RTR] (my prefixes): {vrp_update}")
    }

    if vrp_update.prefix().covered_by(BOGONS) {
        output.timestamped_print(f"[RTR] (BOGON): {vrp_update}")
    }

    accept
}

// After applying a VRP update, this function is called for every stored route
// that was affected by the new VRP. Note that even renewals that did not cause a
// change in ROV status will trigger the call to this function.
//
// NB: this function is only called for updates caused by RTR Serial updates, not
// during the initial RTR Cache Reset sync or when calling `rpki.check_rov` from
// the `rib_in_pre` filter.
fn rib_in_rov_status_update(rov_update: RovStatusUpdate) {

    if rov_update.has_changed() {
        // Only log whenever something changed to Invalid:
        if rov_update.current_status().is_invalid() {
            output.timestamped_print(f"[RTR] {fmt_rov_upd(rov_update)}")
        }
    }

    // When it concerns any of 'my_prefixes', always log:
    if rov_update.prefix().covered_by(MY_PREFIXES) {
        output.timestamped_print(f"[RTR] (my prefixes): {fmt_rov_upd(rov_update)}")
    }
}

fn fmt_rov_upd(r: RovStatusUpdate) -> String {
    f"[{r.previous_status()}] -> [{r.current_status()}] \
    {r.prefix()} originated by {r.origin()}, learned from {r.peer_asn()}"
}



// The ribs/$address_family/routes endpoints take a
// `?function[roto]=$function_name` parameter, which enables the use of a roto
// filter like the one below to be applied on the result set.
// 
// The filter is applied to every route in the (candidate) result set. When
// returning 'reject', the route will not be part of the returned results.
//
// In this example, we only wants routes that contain both an OTC and an AS_PATH
// path attribute, but where the OTC ASN does not appear within that AS_PATH.
filter hidden_hop(attr: PathAttributes) {
    match attr.otc() {
        Some(otc) => {
            match attr.aspath() {
                Some(aspath) => {
                    if !aspath.contains(otc) {
                        accept
                    } else {
                        reject
                    }
                },
                None => reject,
            }
        }
        None => { reject }
    }
}

// Another example based on (Large) communities.
filter specific_communities(attr: PathAttributes) {
    if !contains_community(attr.communities(), Community.NO_PEER) &&
       contains_large_community(attr.large_communities(), LargeCommunity.from("AS211321:1:2")) {
        accept
    }
    reject
}




// Helper and formatting methods.



// Helper method to 'shortcut' the Option, returning false when the Option is
// None. 
fn aspath_contains(maybe: Option[AsPath], to_match: Asn) -> bool {
    match maybe {
        Some(aspath) if aspath.contains(to_match) => true,
        _ => false
    }
}

fn aspath_origin_matches(maybe: Option[AsPath], to_match: Asn) -> bool {
    match maybe {
        Some(aspath) => {
            match aspath.origin() {
                Some(asn) => asn == to_match,
                None => false,
            }
        }
        _ => false
    }
}


fn contains_community(maybe: Option[List[Community]], to_match: Community) -> bool {
    match maybe {
        Some(communities) if communities.contains(to_match) => true,
        _ => false
    }
}

fn contains_large_community(maybe: Option[List[LargeCommunity]], to_match: LargeCommunity) -> bool {
    match maybe {
        Some(communities) if communities.contains(to_match) => true,
        _ => false
    }
}

fn fmt_aspath_origin(r: Route) -> String {
    match r.aspath() {
        Some(aspath) => {
            match aspath.origin() {
                Some(asn) => f"{asn}",
                None => "no origin in aspath"
            }
        }
        None => "no aspath in route"
    }
}

fn fmt_communities(c: Option[List[Community]]) -> String {
    let list = match c {
        Some(list) => list,
        None => return "",
    };
    let strings = List.new();
    for c in list {
        strings.push(c.to_string());
    }

    f"[{strings.join(", ")}]"
}