Skip to content

Instantly share code, notes, and snippets.

@tonusoo
Created March 16, 2026 09:08
Show Gist options
  • Select an option

  • Save tonusoo/3e3cc16fe2008fcb9b47aa6e4831c73d to your computer and use it in GitHub Desktop.

Select an option

Save tonusoo/3e3cc16fe2008fcb9b47aa6e4831c73d to your computer and use it in GitHub Desktop.
RTBH prefixes validation on Junos || PoC setup || NANOG mailing list thread: https://lists.nanog.org/archives/list/nanog@lists.nanog.org/thread/O5XX6BHOSMINX4HKT2SMVOI66SMYXFOR/
root@vjr-17> show configuration routing-options bmp
/* Rotonda */
station BMP-feed-for-RTBH-1 {
/* if the session breaks, then the router tries to reestablish it every 30 seconds */
connection-mode active;
/* Route Monitoring messages are sent only for peers defined under specific BGP groups */
route-monitoring {
none;
}
station-address 10.10.8.3;
station-port 10179;
}
/* Rotonda */
station BMP-feed-for-RTBH-2 {
connection-mode active;
route-monitoring {
none;
}
station-address 10.10.9.3;
station-port 10179;
}
/* pmbmpd (pmacct BMP collector daemon) */
station BMP-full-feed {
connection-mode active;
station-address 10.5.5.13;
station-port 10179;
}
root@vjr-17>
root@vjr-17> show configuration protocols bgp group IPT-fulltable-v4 bmp
monitor enable;
route-monitoring {
pre-policy;
}
root@vjr-17>
root@vjr-17> show configuration protocols bgp group IPT-fulltable-v6 bmp
monitor enable;
route-monitoring {
pre-policy;
}
root@vjr-17>
root@vjr-17> show configuration routing-options validation
group RPKI-validators {
/* Routinator 3000 */
session 10.10.8.2 {
port 3323;
}
/* Routinator 3000 */
session 10.10.9.2 {
port 3323;
}
}
group RTR-sessions-for-RTBH {
/* RTRTR / aggressive timers to prevent stale records / 10.10.8.3 is same server as 10.10.8.2 */
session 10.10.8.3 {
/* send the Serial Query every 10 seconds */
refresh-time 10;
/* drop the session if 30 seconds have passed since the last received PDU */
hold-time 30;
/* flush the route validation records if 60 seconds have passed since the last received PDU */
record-lifetime 60;
port 3323;
}
/* RTRTR / aggressive timers to prevent stale records / 10.10.9.3 is same server as 10.10.9.2 */
session 10.10.9.3 {
refresh-time 10;
hold-time 30;
record-lifetime 60;
port 3323;
}
}
root@vjr-17>
root@vjr-17> show configuration policy-options policy-statement rpki-check
term reject-rpki-invalid {
from {
protocol bgp;
validation-database invalid;
}
then {
validation-state invalid;
reject;
}
}
term mark-rpki-valid {
from {
protocol bgp;
validation-database valid;
}
then {
validation-state valid;
next policy;
}
}
term skip-rpki-unknown {
from {
protocol bgp;
validation-database unknown;
}
then {
validation-state unknown;
next policy;
}
}
then next policy;
root@vjr-17>
root@vjr-17> show configuration policy-options policy-statement ipt-rtbh-v4
term accept-blackhole {
from {
family inet;
community blackhole;
route-filter 0.0.0.0/0 prefix-length-range /32-/32;
}
then {
local-preference 170;
origin igp;
/* requires accept-remote-nexthop */
next-hop 192.0.2.1;
accept;
}
}
term reject-invalid-length {
from {
family inet;
community blackhole;
}
then reject;
}
then next policy;
root@vjr-17>
root@vjr-17> show configuration policy-options policy-statement ipt-rtbh-v6
term accept-blackhole {
from {
family inet6;
community blackhole;
route-filter ::/0 prefix-length-range /128-/128;
}
then {
local-preference 170;
origin igp;
/* requires accept-remote-nexthop */
next-hop 100::1;
accept;
}
}
term reject-invalid-length {
from {
family inet6;
community blackhole;
}
then reject;
}
then next policy;
root@vjr-17>
@tonusoo
Copy link
Author

tonusoo commented Mar 16, 2026

Rotonda configuration file /etc/rotonda/rotonda.conf:

log_level = "debug"
log_target = "syslog"
log_facility = "daemon"

roto_script = "/etc/rotonda/filters.roto"

http_listen = [ "0.0.0.0:8008" ]

[units.bmp-in]
type = "bmp-tcp-in"
listen = "10.10.8.3:10179"

[units.rib]
type = "rib"
sources = [ "bmp-in" ]

[targets.null]
type = "null-out"
sources = [ "rib" ]

Rotonda RIB filter file /etc/rotonda/filters.roto:

# https://rotonda.docs.nlnetlabs.nl/en/v0.5.1/roto/02_rotonda_std.html
filter rib_in_pre(
    route: Route,
    ingress_info: IngressInfo,
) {

    let prefix_str = route.prefix().to_string();
    # RFC 7999 BGP well-known BLACKHOLE community.
    #let rtbh_community = Community.from("65535:666");

    if prefix_str.contains(":") {

        if prefix_str.ends_with("/128") {

            # Relax the filter. Otherwise, BGP UPDATE messages
            # with path attributes changes not containing the
            # BLACKHOLE community will be rejected. For example,
            # a /32 v4 prefix with community 65535:666 was accepted
            # by the filter and installed to Rotonda RIB. Now
            # the community 65535:666 was replaced with
            # 65535:777. With strict filtering this update
            # would get rejected and Rotonda RIB would erroneously
            # contain the prefix with 65535:666 community.
            #
            # if route.contains_community(rtbh_community) {
            #     accept
            # }
            # # As AS_PATH attribute is missing, then it's
            # # a WITHDRAWAL message.
            # else if route.has_attribute(2) == false {
            #     accept
            # }
            # else {
            #     reject
            # }

            accept

        }
        else {
            reject
        }

    }
    else if prefix_str.contains(".") {

        if prefix_str.ends_with("/32") {

            # if route.contains_community(rtbh_community) {
            #     accept
            # }
            # else if route.has_attribute(2) == false {
            #     accept
            # }
            # else {
            #     reject
            # }

            accept

        }
        else {
            reject
        }

    }
    else {
        reject
    }
}

@tonusoo
Copy link
Author

tonusoo commented Mar 16, 2026

Routinator 3000 configuration file /etc/routinator/routinator.conf:

log-level = "debug"

repository-dir = "/var/lib/routinator/rpki-cache"

rtr-listen = [ "10.10.8.2:3323" ]
http-listen = [ "0.0.0.0:8323" ]

@tonusoo
Copy link
Author

tonusoo commented Mar 16, 2026

Script which fetches blackhole routes from Rotonda RIB using its API, checks those routes against Routinator 3000 API and routes which have a covering VRP with ASN matching the origin ASN of the prefix, are written atomically to a JSON file:

martin@validator-1:~$ systemctl cat rtbh-generator.service
# /etc/systemd/system/rtbh-generator.service
[Unit]
Description=RTBH JSON Generator Daemon
After=network.target routinator.service rotonda.service

[Service]
Type=simple
User=rtbh-generator
ExecStart=/usr/local/bin/rtbh-daemon.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
martin@validator-1:~$
martin@validator-1:~$ cat /usr/local/bin/rtbh-daemon.py
#!/usr/bin/env python3

import os
import time
import json
import logging
import ipaddress
import requests

ROTONDA_V4_URL = "http://127.0.0.1:8008/api/v1/ribs/ipv4unicast/routes"
ROTONDA_V6_URL = "http://127.0.0.1:8008/api/v1/ribs/ipv6unicast/routes"
ROUTINATOR_URL = "http://127.0.0.1:8323/validity"
TARGET_COMMUNITY = "BLACKHOLE"
OUTPUT_FILE = "/var/lib/rtbh-generator/rtbh.json"
POLL_INTERVAL = 1

# Setup logging.
logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s")


def get_rotonda_candidates(url, expected_length):
    """Fetches routes from Rotonda and filters for /32 or /128 with
    the target community.
    """

    candidates = {}
    try:
        resp = requests.get(url, timeout=10)
        resp.raise_for_status()
        data = resp.json()
    except Exception as e:
        logging.error(f"Failed to fetch data from {url}: {e}")
        return candidates

    more_specifics = data.get("included", {}).get("moreSpecifics", [])

    for item in more_specifics:
        nlri = item.get("nlri")
        if not nlri:
            continue

        # Sanity check the prefix length.
        try:
            network = ipaddress.ip_network(nlri)
        except ValueError:
            continue

        if network.prefixlen != expected_length:
            continue

        # Check path attributes of active routes.
        for route in item.get("routes", []):
            if route.get("status") != "active":
                continue

            path_attrs = route.get("pathAttributes", [])
            communities = []
            as_path = []

            for attr in path_attrs:
                if "standardCommunities" in attr:
                    communities = attr["standardCommunities"]
                if "asPath" in attr:
                    as_path = attr["asPath"]

            # Validate target community presence and extract origin ASN.
            if TARGET_COMMUNITY in communities and as_path:

                # The last ASN in the path is the origin.
                origin_asn = as_path[-1]
                candidates[nlri] = origin_asn
                # Found a valid active path for this prefix,
                # skip to next prefix.
                break

    return candidates


def check_routinator(prefix, origin_asn):
    """Checks Routinator to ensure a covering VRP matches
    the origin ASN.
    """

    # Rotonda returns ASN as "AS12345", Routinator
    # query expects "12345".
    asn_num = origin_asn.lstrip("AS")

    try:
        params = {"asn": asn_num, "prefix": prefix}
        resp = requests.get(ROUTINATOR_URL, params=params, timeout=10)
        resp.raise_for_status()
        data = resp.json()
    except Exception as e:
        logging.error(
            f"Failed to check Routinator for {prefix} (AS{asn_num}): {e}"
        )
        return False

    vrps = data.get("validated_route", {}).get("validity", {}).get("VRPs", {})

    # We need at least one covering ROA where the ASN explicitly matches
    # our origin_asn.
    for category in ["matched", "unmatched_length"]:
        for vrp in vrps.get(category, []):
            if vrp.get("asn") == origin_asn:
                return True

    return False


def write_rtbh_file(valid_routes):
    """Writes the validated prefixes to the JSON file for
    rtrtr atomically.
    """

    roas = []
    for prefix, origin_asn in valid_routes.items():
        try:
            network = ipaddress.ip_network(prefix)
            roas.append(
                {
                    "asn": origin_asn,
                    "prefix": prefix,
                    "maxLength": network.prefixlen,
                    "ta": "local-rtbh",
                }
            )
        except ValueError:
            continue

    output_data = {"roas": roas}
    temp_file = f"{OUTPUT_FILE}.tmp"

    try:
        with open(temp_file, "w") as f:
            json.dump(output_data, f, indent=2)
            f.write("\n")

        # Atomic replace guarantees rtrtr never reads a partial file.
        os.replace(temp_file, OUTPUT_FILE)
        logging.info(
            f"Successfully wrote {len(roas)} valid RTBH ROAs to {OUTPUT_FILE}"
        )
    except Exception as e:
        logging.error(f"Failed to write RTBH JSON file: {e}")


def main():

    logging.info("Starting RTBH Generator Daemon...")

    while True:
        valid_routes = {}

        # Fetch and filter from Rotonda.
        v4_candidates = get_rotonda_candidates(ROTONDA_V4_URL, 32)
        v6_candidates = get_rotonda_candidates(ROTONDA_V6_URL, 128)

        all_candidates = {**v4_candidates, **v6_candidates}

        for prefix, origin_asn in all_candidates.items():

            if check_routinator(prefix, origin_asn):
                valid_routes[prefix] = origin_asn
            else:
                logging.info(
                    f"Prefix {prefix} (Origin: {origin_asn}) rejected "
                    "by Routinator check."
                )

        write_rtbh_file(valid_routes)
        time.sleep(POLL_INTERVAL)


if __name__ == "__main__":
    main()
martin@validator-1:~$

@tonusoo
Copy link
Author

tonusoo commented Mar 16, 2026

RTRTR configuration file /etc/rtrtr.conf:

log-level = "debug"
log = "syslog"
syslog-facility = "daemon"

http-listen = [ "0.0.0.0:8080" ]

[units.rtbh]
type = "json"
uri = "file:///var/lib/rtbh-generator/rtbh.json"
refresh = 1

[targets.local-3323]
type = "rtr"
unit = "rtbh"
listen = [ "10.10.8.3:3323" ]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment