Semana 7 — Los problemas de BGP y la estructura de Internet

Objetivos

  • Explicar por qué BGP confía en lo que le anuncian y qué consecuencias tiene.
  • Analizar hijacks y route leaks reales.
  • Explicar cómo RPKI valida el origen de una ruta y qué no resuelve.
  • Describir la estructura real de Internet: tránsito, peering, IXPs y grandes redes de contenido.
  • Repasar las partes 1 a 3 para el examen parcial.

Sesión A — Los problemas de BGP: hijacks, route leaks y RPKI

Lecturas:

Lecturas complementarias:

Temas:

  • BGP no tiene una forma propia de verificar lo que se le anuncia.
  • Prefix hijack: anunciar un prefijo ajeno. Por qué un prefijo más específico gana en todas partes, por longest prefix match. Caso: Pakistan Telecom y YouTube, en 2008.
  • Route leak: un AS reanuncia rutas violando las reglas de exportación. Caso: el leak de junio de 2019 que afectó a Cloudflare y a otras redes.
  • Defensas: filtros de prefijos en clientes, límites de cantidad de prefijos y buenas prácticas de operadores.
  • RPKI: ROAs y validación de origen, con los resultados valid, invalid y not found. Qué no protege: BGPsec y validación del camino.
  • Error común: creer que RPKI cifra BGP o evita todo hijack. Solo ayuda a validar quién puede originar un prefijo, y solo si las redes la aplican.
  • Fallas que no son ataques: un error de configuración también puede retirar una red entera de Internet. Caso: Facebook, en octubre de 2021.

Sesión B — IXPs y estructura real de Internet · Repaso

Lecturas:

Temas:

  • De la jerarquía a una red más plana: las redes de contenido llegan directo a los ISPs.
  • Tránsito y peering: cuánto cuesta cada uno y por qué un ISP decide tener peering con otro.
  • Qué es un IXP y cómo acorta caminos y baja costos.
  • Cables submarinos y por qué importa en un país como el Perú. Medirlo con los traceroute de la semana 1.
  • Repaso guiado de las semanas 1 a 6, con problemas del tipo del examen.

Laboratorio — Provocar un route hijack y defenderse

Trabajen en code/week-07/, con la topología de bgp/.

  1. Comprobar que un host del AS 64601 alcanza un servidor del AS 64603.
  2. Desde un tercer AS, anunciar el mismo prefijo. Ver qué ASes cambian de ruta y cuáles no.
  3. Anunciar un prefijo más específico. Comprobar que ahora el tráfico se desvía hacia el atacante, y explicar por qué.
  4. Provocar un route leak, quitando las reglas de exportación de un AS.
  5. Aplicar en el proveedor un filtro con prefix-list que acepte de cada cliente solo lo que le corresponde. Repetir los ataques. Este filtro depende de esta topología: en P2 tienen que escribir el suyo.
  6. Discutir en grupo: ¿qué tendría que cambiar para que RPKI hubiera detenido cada ataque?
  7. Avanzar con P2.

Preguntas de repaso

  1. Un atacante anuncia 203.0.113.0/25 y la víctima anuncia 203.0.113.0/24. ¿A dónde va el tráfico hacia 203.0.113.5? ¿Y hacia 203.0.113.200?
  2. ¿Cómo responde la víctima de un hijack si no controla los filtros de los demás?
  3. Dado un AS que recibe de un cliente una ruta a un prefijo de otra red, ¿qué filtro lo habría evitado?
  4. Un ROA autoriza al AS 64600 a originar 198.51.100.0/24 con largo máximo 24. Clasifiquen como valid, invalid o not found varios anuncios dados.
  5. ¿Por qué tener un IXP local reduce el retardo entre dos ISPs de la misma ciudad?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (25)Pruebas (3)Laboratorio (2)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

netlab.pyDescargar netlab.py
"""Ayudas de las pruebas de la semana 7: levantar topologías y correr comandos."""
import os
import subprocess
import time
import unittest

HERE = os.path.dirname(os.path.abspath(__file__))
# La solución no se publica. Sin ella, las pruebas usan lo que ustedes escriben en lab/.
ANSWERS = os.path.join(HERE, "solution")
if not os.path.isdir(ANSWERS):
    ANSWERS = os.path.join(HERE, "lab")


def answer_path(name):
    """Devuelve la ruta de un archivo del laboratorio, o salta si sigue con el TODO."""
    path = os.path.join(ANSWERS, name)
    with open(path, encoding="utf-8") as file:
        if "TODO" in file.read():
            raise unittest.SkipTest(f"falta completar lab/{name}")
    return path


def run(args, timeout=240, stdin=None):
    return subprocess.run(args, capture_output=True, text=True, timeout=timeout, stdin=stdin)


class Lab:
    """Una topología de Containerlab que se levanta y se destruye desde las pruebas."""

    def __init__(self, folder, name, network):
        self.folder = os.path.join(HERE, folder)
        self.topology = f"{folder}.clab.yml"
        self.prefix = f"clab-{name}-"
        self.network = network

    def deploy(self):
        done = subprocess.run(
            ["containerlab", "deploy", "-t", self.topology, "--reconfigure"],
            cwd=self.folder, capture_output=True, text=True, timeout=300)
        if done.returncode != 0:
            raise RuntimeError(done.stdout + done.stderr)

    def destroy(self):
        subprocess.run(["containerlab", "destroy", "-t", self.topology, "--cleanup"],
                       cwd=self.folder, capture_output=True, text=True, timeout=300)
        subprocess.run(["docker", "network", "rm", self.network], capture_output=True, text=True)

    def sh(self, node, *command, timeout=60):
        """Corre un comando en un nodo y devuelve el resultado completo."""
        return run(["docker", "exec", self.prefix + node, *command], timeout=timeout)

    def out(self, node, *command, timeout=60):
        """Corre un comando en un nodo y devuelve stdout y stderr juntos."""
        done = self.sh(node, *command, timeout=timeout)
        return done.stdout + done.stderr

    def daemon(self, node, command):
        """Deja un comando corriendo en segundo plano dentro de un nodo."""
        run(["docker", "exec", "-d", self.prefix + node, "sh", "-c", command])



def wait_until(predicate, seconds=30, step=0.5):
    """Espera a que una condición se cumpla; devuelve si se cumplió."""
    deadline = time.monotonic() + seconds
    while time.monotonic() < deadline:
        if predicate():
            return True
        time.sleep(step)
    return predicate()
rov.cDescargar rov.c
/* rov: clasifica un anuncio contra una lista de VRPs, como manda el RFC 6811.
 *
 *   ./rov <archivo-de-vrps> <prefijo/largo> <as-de-origen>
 *
 * Un VRP es un ROA ya validado: prefijo, largo máximo y AS autorizado.
 * Solo IPv4, para que se lea el algoritmo.
 */
#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct prefix {
    uint32_t address; /* en orden de host */
    int length;
};

static int parse_prefix(const char *text, struct prefix *out)
{
    char copy[32];
    if (strlen(text) >= sizeof copy)
        return -1;
    strcpy(copy, text);
    char *slash = strchr(copy, '/');
    if (slash == NULL)
        return -1;
    *slash = '\0';

    char *end;
    errno = 0;
    long length = strtol(slash + 1, &end, 10);
    if (errno != 0 || *end != '\0' || end == slash + 1 || length < 0 || length > 32)
        return -1;

    struct in_addr address;
    if (inet_pton(AF_INET, copy, &address) != 1)
        return -1;
    out->address = ntohl(address.s_addr);
    out->length = (int)length;
    return 0;
}

/* ¿Los primeros `length` bits de las dos direcciones son iguales? */
static int same_bits(uint32_t a, uint32_t b, int length)
{
    if (length == 0)
        return 1;
    uint32_t mask = 0xFFFFFFFFu << (32 - length);
    return (a & mask) == (b & mask);
}

/* Covered: el VRP es igual o más corto que la ruta y sus bits coinciden. */
static int covers(const struct prefix *vrp, const struct prefix *route)
{
    return vrp->length <= route->length && same_bits(vrp->address, route->address, vrp->length);
}

int main(int argc, char *argv[])
{
    if (argc != 4) {
        fprintf(stderr, "uso: %s <archivo-de-vrps> <prefijo/largo> <as-de-origen>\n", argv[0]);
        return 2;
    }

    struct prefix route;
    if (parse_prefix(argv[2], &route) != 0) {
        fprintf(stderr, "prefijo inválido: %s\n", argv[2]);
        return 2;
    }
    char *end;
    errno = 0;
    unsigned long origin = strtoul(argv[3], &end, 10);
    if (errno != 0 || *end != '\0' || end == argv[3] || origin > UINT32_MAX) {
        fprintf(stderr, "AS inválido: %s\n", argv[3]);
        return 2;
    }

    FILE *file = fopen(argv[1], "r");
    if (file == NULL) {
        perror(argv[1]);
        return 2;
    }

    int covered = 0, matched = 0;
    char line[256];
    while (fgets(line, sizeof line, file) != NULL) {
        char *comment = strchr(line, '#');
        if (comment != NULL)
            *comment = '\0';
        char text[64];
        int max_length;
        unsigned long asn;
        int fields = sscanf(line, "%63s %d %lu", text, &max_length, &asn);
        if (fields == EOF || fields == 0)
            continue;
        struct prefix vrp;
        if (fields != 3 || parse_prefix(text, &vrp) != 0) {
            fprintf(stderr, "línea inválida: %s", line);
            fclose(file);
            return 2;
        }
        if (!covers(&vrp, &route))
            continue;
        covered = 1;
        int length_ok = route.length <= max_length;
        /* AS 0 nunca autoriza a nadie: un ROA con AS 0 dice "esto no se anuncia". */
        int origin_ok = asn != 0 && origin == asn;
        printf("  cubierto por %s max %d AS %lu: largo %s, origen %s\n", text, max_length, asn,
               length_ok ? "ok" : "demasiado largo", origin_ok ? "ok" : "no coincide");
        if (length_ok && origin_ok)
            matched = 1;
    }
    if (ferror(file)) {
        perror(argv[1]);
        fclose(file);
        return 2;
    }
    if (fclose(file) != 0) {
        perror(argv[1]);
        return 2;
    }

    puts(matched ? "valid" : covered ? "invalid" : "not found");
    return 0;
}
scenarios.pyDescargar scenarios.py
#!/usr/bin/env python3
"""Lo que ocurre en la topología bgp/: ataques, fugas y defensas, uno por uno.

    python3 scenarios.py <escenario>     aplica un escenario
    python3 scenarios.py reset           vuelve al estado inicial
    python3 scenarios.py                 lista los escenarios

Cada escenario es la lista de comandos de vtysh o de shell que se corren en cada nodo:
lean este archivo, porque los comandos son la mitad de lo que hay que aprender.
"""
import os
import sys

from netlab import ANSWERS, Lab, run

LAB = Lab("bgp", "w07-bgp", "redes-w07-bgp")

A_R1, A_R6, A_R4 = "10.207.4.1", "10.207.5.1", "10.207.6.1"
B_R4, B_R3 = "10.207.7.1", "10.207.8.1"
R4_A, R4_B = "10.207.6.2", "10.207.7.2"
VICTIM = "100.64.3.0/24"
HALVES = ("100.64.3.0/25", "100.64.3.128/25")
SERVER = "100.64.3.10"
PORT_STRICT, PORT_LAX = 8282, 8283


def conf(node, *lines):
    """Corre líneas de configuración en vtysh, en una sola sesión."""
    args = ["vtysh", "-c", "configure terminal"]
    for line in lines:
        args += ["-c", line]
    return LAB.out(node, *args)


def in_bgp(asn, *lines):
    return ("router bgp " + str(asn), "address-family ipv4 unicast", *lines, "end")


# --- El atacante, r6 (AS 64666) ---------------------------------------------------

def same():
    """r6 origina el mismo /24 de la víctima y contesta en la dirección del servidor."""
    LAB.sh("r6", "ip", "addr", "add", SERVER + "/32", "dev", "dum0")
    conf("r6", f"ip route {VICTIM} Null0", *in_bgp(64666, f"network {VICTIM}"))


def specific():
    """r6 origina solo la mitad del /24 donde está el servidor: un /25."""
    LAB.sh("r6", "ip", "addr", "add", SERVER + "/32", "dev", "dum0")
    conf("r6", f"ip route {HALVES[0]} Null0", *in_bgp(64666, f"network {HALVES[0]}"))


def forged():
    """El /24 otra vez, pero con el AS de la víctima al final del AS_PATH."""
    conf("r6", "ip prefix-list ROBADO seq 5 permit " + VICTIM,
         "route-map FORJAR permit 10", "match ip address prefix-list ROBADO",
         "set as-path prepend 64603", "route-map FORJAR permit 20", "exit",
         *in_bgp(64666, "neighbor 10.207.5.2 route-map FORJAR out"))
    same()


def forged_specific():
    """El /25 con el origen forjado: aquí debería importar el largo máximo del ROA."""
    conf("r6", "ip prefix-list ROBADO seq 5 permit " + HALVES[0],
         "route-map FORJAR permit 10", "match ip address prefix-list ROBADO",
         "set as-path prepend 64603", "route-map FORJAR permit 20", "exit",
         *in_bgp(64666, "neighbor 10.207.5.2 route-map FORJAR out"))
    specific()


# --- La víctima, r3 (AS 64603) ----------------------------------------------------

def counter():
    """r3 responde anunciando dos /25: más específicos que el /24 del atacante."""
    conf("r3", *[f"ip route {half} eth1" for half in HALVES],
         *in_bgp(64603, *[f"network {half}" for half in HALVES]))


# --- La fuga, r4 (AS 64604) -------------------------------------------------------

def leak():
    """r4 pierde su regla de exportación: cada proveedor recibe las rutas del otro."""
    conf("r4", *in_bgp(64604, f"no neighbor {R4_A} route-map SOLO-PROPIAS out",
                       f"no neighbor {R4_B} route-map SOLO-PROPIAS out"))


def slow_r4():
    """r4 es una red chica: 5 ms y 20 Mbit/s por enlace. Sirve para ver el costo de la fuga."""
    for dev in ("eth1", "eth2"):
        LAB.sh("r4", "tc", "qdisc", "replace", "dev", dev, "root", "netem",
               "delay", "5ms", "rate", "20mbit", "limit", "200")


# --- Defensas que se muestran en clase --------------------------------------------

def max_prefix():
    """Los proveedores de r4 toleran hasta dos prefijos en esa sesión; luego la cierran."""
    conf("a", *in_bgp(64610, f"neighbor {A_R4} maximum-prefix 2"))
    conf("b", *in_bgp(64620, f"neighbor {B_R4} maximum-prefix 2"))


ROV_NEIGHBORS = {
    "a": (64610, "10.207.9.2", ("10.207.2.2", A_R1, A_R6, A_R4)),
    "d": (64640, "10.207.10.2", ("10.207.1.1", "10.207.3.1")),
}


def rov_on(node, port=PORT_STRICT):
    """El AS se conecta al cache y descarta lo inválido en todas sus sesiones."""
    asn, cache, neighbors = ROV_NEIGHBORS[node]
    conf(node, "rpki", "rpki polling_period 5", f"rpki cache tcp {cache} {port} preference 1",
         "exit", "route-map ROV deny 10", "match rpki invalid", "route-map ROV permit 20", "exit",
         *in_bgp(asn, *[f"neighbor {peer} route-map ROV in" for peer in neighbors]))


def rov_a():
    """Solo a, el proveedor del atacante, aplica validación de origen."""
    rov_on("a")


def rov_a_lax():
    """Como rov_a, pero con un ROA descuidado: el largo máximo de la víctima es 25."""
    rov_on("a", PORT_LAX)


def rov_d():
    """Solo d, en la cima y lejos del atacante, aplica validación de origen."""
    rov_on("d")


# --- Volver al inicio -------------------------------------------------------------

def undo(node, asn, *lines):
    """Borra cada cosa en su propia llamada: vtysh se detiene en el primer error, y es
    normal que lo que se quiere borrar no exista."""
    for line in lines:
        conf(node, *in_bgp(asn, line))


def undo_global(node, *lines):
    for line in lines:
        conf(node, line)


def reset():
    undo("r6", 64666, f"no network {VICTIM}", *[f"no network {h}" for h in HALVES],
         "no neighbor 10.207.5.2 route-map FORJAR out")
    undo_global("r6", f"no ip route {VICTIM} Null0", *[f"no ip route {h} Null0" for h in HALVES],
                "no route-map FORJAR", "no ip prefix-list ROBADO")
    LAB.sh("r6", "ip", "addr", "del", SERVER + "/32", "dev", "dum0")
    undo("r3", 64603, *[f"no network {h}" for h in HALVES])
    undo_global("r3", *[f"no ip route {h} eth1" for h in HALVES])
    conf("r4", *in_bgp(64604, f"neighbor {R4_A} route-map SOLO-PROPIAS out",
                       f"neighbor {R4_B} route-map SOLO-PROPIAS out"))
    for dev in ("eth1", "eth2"):
        LAB.sh("r4", "tc", "qdisc", "del", "dev", dev, "root")
    undo("a", 64610, f"no neighbor {A_R4} maximum-prefix 2",
         *[f"no neighbor {p} route-map ROV in" for p in ROV_NEIGHBORS["a"][2]],
         f"no neighbor {A_R1} prefix-list CLIENTE-R1 in",
         f"no neighbor {A_R6} prefix-list CLIENTE-R6 in",
         f"no neighbor {A_R4} prefix-list CLIENTE-R4 in")
    undo("b", 64620, f"no neighbor {B_R4} maximum-prefix 2",
         f"no neighbor {B_R3} prefix-list CLIENTE-R3 in",
         f"no neighbor {B_R4} prefix-list CLIENTE-R4 in")
    undo("d", 64640, *[f"no neighbor {p} route-map ROV in" for p in ROV_NEIGHBORS["d"][2]])
    for node in ("a", "d"):
        cache = ROV_NEIGHBORS[node][1]
        undo_global(node, "no route-map ROV")
        for port in (PORT_STRICT, PORT_LAX):
            conf(node, "rpki", f"no rpki cache tcp {cache} {port} preference 1")
    undo_global("a", "no ip prefix-list CLIENTE-R1", "no ip prefix-list CLIENTE-R6",
                "no ip prefix-list CLIENTE-R4")
    undo_global("b", "no ip prefix-list CLIENTE-R3", "no ip prefix-list CLIENTE-R4")
    # Una sesión cerrada por maximum-prefix no vuelve sola.
    LAB.out("a", "vtysh", "-c", "clear bgp ipv4 unicast " + A_R4)
    LAB.out("b", "vtysh", "-c", "clear bgp ipv4 unicast " + B_R4)


SCENARIOS = {f.__name__: f for f in (same, specific, forged, forged_specific, counter, leak, slow_r4,
                                     max_prefix, rov_a, rov_a_lax, rov_d, reset)}


def filters(folder):
    """Carga los filtros del laboratorio en los dos proveedores y reaplica la importación."""
    for node, name in (("a", "filters-a.conf"), ("b", "filters-b.conf")):
        path = os.path.join(folder, name)
        done = run(["docker", "cp", path, f"{LAB.prefix}{node}:/tmp/filters.conf"])
        if done.returncode != 0:
            raise RuntimeError(done.stderr)
        done = LAB.sh(node, "vtysh", "-f", "/tmp/filters.conf")
        if done.returncode != 0:
            raise RuntimeError(done.stdout + done.stderr)
        LAB.out(node, "vtysh", "-c", "clear bgp ipv4 unicast * soft in")


def main():
    if len(sys.argv) == 2 and sys.argv[1] == "filters":
        filters(ANSWERS)
        return 0
    if len(sys.argv) != 2 or sys.argv[1] not in SCENARIOS:
        for name, function in SCENARIOS.items():
            print(f"{name:16} {(function.__doc__ or 'vuelve al estado inicial').strip()}")
        print(f"{'filters':16} carga sus filtros de lab/ en a y en b")
        return 1
    SCENARIOS[sys.argv[1]]()
    return 0


if __name__ == "__main__":
    sys.exit(main())
vrps-example.txtDescargar vrps-example.txt
# Los VRPs de los ejemplos de los apuntes. Columnas: prefijo, largo máximo, AS autorizado.
198.51.100.0/24   24   64600
203.0.113.0/24    25   64601
192.0.2.0/24      24   0
10.0.0.0/8        16   64700
10.0.0.0/8        8    64701
bgp/bgp.clab.ymlDescargar bgp/bgp.clab.yml
# Seis sistemas autónomos en jerarquía, más dos proveedores grandes en la cima. r6 es
# el atacante, r4 es el cliente con dos proveedores y cache es el servidor RTR.
#
#      AS 64630  c ----peer---- d  AS 64640
#                |              |
#      AS 64610  a              b  AS 64620
#              / | \           / \
#            r1  r6  \        /   r3
#            |        \      /     |
#            h1        r4 ---     srv
#                     AS 64604
#
#   r1 AS 64601 (h1), r3 AS 64603 (srv), r6 AS 64666 (atacante)
name: w07-bgp
mgmt:
  network: redes-w07-bgp
  ipv4-subnet: 172.90.7.0/26
  ipv6-subnet: 3fff:172:90:7::/80
topology:
  defaults:
    kind: linux
    image: redes-router
  nodes:
    # Se borra la ruta por defecto de gestión (eth0): lo que un nodo no sabe a dónde
    # mandar debe fallar, no irse por Docker. IPv6 se apaga para no ensuciar capturas.
    c:
      binds: [c/frr.conf:/etc/frr/frr.conf:ro]
      exec: &router-exec
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
    d:
      binds: [d/frr.conf:/etc/frr/frr.conf:ro, daemons-rpki:/etc/frr/daemons]
      exec: *router-exec
    a:
      binds: [a/frr.conf:/etc/frr/frr.conf:ro, daemons-rpki:/etc/frr/daemons]
      exec: *router-exec
    b:
      binds: [b/frr.conf:/etc/frr/frr.conf:ro]
      exec: *router-exec
    r1:
      binds: [r1/frr.conf:/etc/frr/frr.conf:ro]
      exec: *router-exec
    r3:
      binds: [r3/frr.conf:/etc/frr/frr.conf:ro]
      exec: *router-exec
    # r4 y r6 anuncian su bloque desde una interfaz dummy: no necesitan una LAN.
    r4:
      binds: [r4/frr.conf:/etc/frr/frr.conf:ro]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip link add dum0 type dummy
        - ip link set dum0 up
    r6:
      binds: [r6/frr.conf:/etc/frr/frr.conf:ro, www/attacker.html:/www/index.html]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip link add dum0 type dummy
        - ip link set dum0 up
        - sh -c 'python3 -m http.server 8080 --directory /www >/dev/null 2>&1 &'
    h1:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 100.64.1.10/24 dev eth1
        - ip route add default via 100.64.1.1
    srv:
      binds: [www/server.html:/www/index.html]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 100.64.3.10/24 dev eth1
        - ip addr add 100.64.3.200/24 dev eth1
        - ip route add default via 100.64.3.1
        - sh -c 'python3 -m http.server 8080 --directory /www >/dev/null 2>&1 &'
    cache:
      binds: [cache:/cache]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.207.9.2/30 dev eth1
        - ip addr add 10.207.10.2/30 dev eth2
        - sh -c 'python3 /cache/rtr_cache.py /cache/vrps.txt >/tmp/rtr.log 2>&1 &'
        - sh -c 'python3 /cache/rtr_cache.py /cache/vrps-lax.txt --port 8283 >/tmp/rtr-lax.log 2>&1 &'
  # Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
  links:
    - {endpoints: ["c:eth1", "d:eth1"], mtu: 1500}
    - {endpoints: ["a:eth1", "c:eth2"], mtu: 1500}
    - {endpoints: ["b:eth1", "d:eth2"], mtu: 1500}
    - {endpoints: ["r1:eth2", "a:eth2"], mtu: 1500}
    - {endpoints: ["r6:eth1", "a:eth3"], mtu: 1500}
    - {endpoints: ["r4:eth1", "a:eth4"], mtu: 1500}
    - {endpoints: ["r4:eth2", "b:eth2"], mtu: 1500}
    - {endpoints: ["r3:eth2", "b:eth3"], mtu: 1500}
    - {endpoints: ["h1:eth1", "r1:eth1"], mtu: 1500}
    - {endpoints: ["srv:eth1", "r3:eth1"], mtu: 1500}
    - {endpoints: ["cache:eth1", "a:eth5"], mtu: 1500}
    - {endpoints: ["cache:eth2", "d:eth3"], mtu: 1500}
bgp/daemons-rpkiDescargar bgp/daemons-rpki
zebra=yes
staticd=yes
bgpd=yes
vtysh_enable=yes
zebra_options="-A 127.0.0.1 -s 90000000"
staticd_options="-A 127.0.0.1"
bgpd_options="-A 127.0.0.1 -M rpki"
ixp/ixp.clab.ymlDescargar ixp/ixp.clab.yml
# Tres ISP de una misma ciudad con un solo proveedor de tránsito, lejos: 40 ms en cada
# sentido por enlace, como el cable a Miami de la semana 1. Hay también un IXP en la
# ciudad (el switch ix), pero al comienzo nadie tiene sesiones por ahí.
#
#                     t  AS 64710
#                   / | \
#         40 ms   /   |   \   40 ms
#              isp1  isp2  isp3     AS 64701, 64702, 64703
#              | \   | \   | \
#             h1  \  h2 \  h3 \
#                  \------+-----\----- ix (10.208.100.0/24)
name: w07-ixp
mgmt:
  network: redes-w07-ixp
  ipv4-subnet: 172.90.7.64/26
  ipv6-subnet: 3fff:172:90:7:1::/80
topology:
  defaults:
    kind: linux
    image: redes-router
  nodes:
    t:
      binds: [t/frr.conf:/etc/frr/frr.conf:ro]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - tc qdisc add dev eth1 root netem delay 40ms
        - tc qdisc add dev eth2 root netem delay 40ms
        - tc qdisc add dev eth3 root netem delay 40ms
    isp1:
      binds: [isp1/frr.conf:/etc/frr/frr.conf:ro]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - tc qdisc add dev eth2 root netem delay 40ms
    h1:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 100.64.71.10/24 dev eth1
        - ip route add default via 100.64.71.1
    isp2:
      binds: [isp2/frr.conf:/etc/frr/frr.conf:ro]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - tc qdisc add dev eth2 root netem delay 40ms
    h2:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 100.64.72.10/24 dev eth1
        - ip route add default via 100.64.72.1
    isp3:
      binds: [isp3/frr.conf:/etc/frr/frr.conf:ro]
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - tc qdisc add dev eth2 root netem delay 40ms
    h3:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 100.64.73.10/24 dev eth1
        - ip route add default via 100.64.73.1
    # El IXP es un switch: un dominio de broadcast compartido, nada más.
    ix:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip link add br0 type bridge
        - ip link set eth1 master br0
        - ip link set eth2 master br0
        - ip link set eth3 master br0
        - ip link set br0 up
  # Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
  links:
    - {endpoints: ["h1:eth1", "isp1:eth1"], mtu: 1500}
    - {endpoints: ["h2:eth1", "isp2:eth1"], mtu: 1500}
    - {endpoints: ["h3:eth1", "isp3:eth1"], mtu: 1500}
    - {endpoints: ["isp1:eth2", "t:eth1"], mtu: 1500}
    - {endpoints: ["isp2:eth2", "t:eth2"], mtu: 1500}
    - {endpoints: ["isp3:eth2", "t:eth3"], mtu: 1500}
    - {endpoints: ["isp1:eth3", "ix:eth1"], mtu: 1500}
    - {endpoints: ["isp2:eth3", "ix:eth2"], mtu: 1500}
    - {endpoints: ["isp3:eth3", "ix:eth3"], mtu: 1500}
ixp/peering.shDescargar ixp/peering.sh
#!/bin/sh
# Abre (o cierra, con "off") el peering entre isp1 e isp2 por la LAN del IXP.
# Cada lado anuncia solo lo que origina: un peer no es un proveedor.
set -eu
c1=clab-w07-ixp-isp1
c2=clab-w07-ixp-isp2
no=
[ "${1:-on}" = off ] && no="no "
if [ -z "$no" ]; then
  docker exec "$c1" vtysh -c 'configure terminal' -c 'router bgp 64701' \
    -c 'neighbor 10.208.100.2 remote-as 64702' \
    -c 'neighbor 10.208.100.2 description isp2 (peer por el IXP)' \
    -c 'neighbor 10.208.100.2 advertisement-interval 0' \
    -c 'address-family ipv4 unicast' -c 'neighbor 10.208.100.2 route-map SOLO-PROPIAS out'
  docker exec "$c2" vtysh -c 'configure terminal' -c 'router bgp 64702' \
    -c 'neighbor 10.208.100.1 remote-as 64701' \
    -c 'neighbor 10.208.100.1 description isp1 (peer por el IXP)' \
    -c 'neighbor 10.208.100.1 advertisement-interval 0' \
    -c 'address-family ipv4 unicast' -c 'neighbor 10.208.100.1 route-map SOLO-PROPIAS out'
else
  docker exec "$c1" vtysh -c 'configure terminal' -c 'router bgp 64701' -c 'no neighbor 10.208.100.2'
  docker exec "$c2" vtysh -c 'configure terminal' -c 'router bgp 64702' -c 'no neighbor 10.208.100.1'
fi
bgp/a/frr.confDescargar bgp/a/frr.conf
frr defaults traditional
hostname a
interface eth1
 ip address 10.207.2.1/30
interface eth2
 ip address 10.207.4.2/30
interface eth3
 ip address 10.207.5.2/30
interface eth4
 ip address 10.207.6.2/30
interface eth5
 ip address 10.207.9.1/30
router bgp 64610
 bgp router-id 10.207.0.3
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.2.2 remote-as 64630
 neighbor 10.207.2.2 description c (proveedor)
 neighbor 10.207.2.2 advertisement-interval 0
 neighbor 10.207.4.1 remote-as 64601
 neighbor 10.207.4.1 description r1 (cliente)
 neighbor 10.207.4.1 advertisement-interval 0
 neighbor 10.207.5.1 remote-as 64666
 neighbor 10.207.5.1 description r6 (cliente)
 neighbor 10.207.5.1 advertisement-interval 0
 neighbor 10.207.6.1 remote-as 64604
 neighbor 10.207.6.1 description r4 (cliente)
 neighbor 10.207.6.1 advertisement-interval 0
 address-family ipv4 unicast
 exit-address-family
bgp/b/frr.confDescargar bgp/b/frr.conf
frr defaults traditional
hostname b
interface eth1
 ip address 10.207.3.1/30
interface eth2
 ip address 10.207.7.2/30
interface eth3
 ip address 10.207.8.2/30
router bgp 64620
 bgp router-id 10.207.0.4
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.3.2 remote-as 64640
 neighbor 10.207.3.2 description d (proveedor)
 neighbor 10.207.3.2 advertisement-interval 0
 neighbor 10.207.7.1 remote-as 64604
 neighbor 10.207.7.1 description r4 (cliente)
 neighbor 10.207.7.1 advertisement-interval 0
 neighbor 10.207.8.1 remote-as 64603
 neighbor 10.207.8.1 description r3 (cliente)
 neighbor 10.207.8.1 advertisement-interval 0
 address-family ipv4 unicast
 exit-address-family
bgp/c/frr.confDescargar bgp/c/frr.conf
frr defaults traditional
hostname c
interface eth1
 ip address 10.207.1.1/30
interface eth2
 ip address 10.207.2.2/30
router bgp 64630
 bgp router-id 10.207.0.1
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.1.2 remote-as 64640
 neighbor 10.207.1.2 description d (peer)
 neighbor 10.207.1.2 advertisement-interval 0
 neighbor 10.207.2.1 remote-as 64610
 neighbor 10.207.2.1 description a (cliente)
 neighbor 10.207.2.1 advertisement-interval 0
 address-family ipv4 unicast
 exit-address-family
bgp/cache/rtr_cache.pyDescargar bgp/cache/rtr_cache.py
#!/usr/bin/env python3
"""Un cache RPKI mínimo: entrega por RTR (RFC 8210) una lista fija de VRPs.

Un validador de verdad descarga los ROAs de los repositorios y comprueba sus firmas
antes de entregarlos. Este programa se salta esa parte: solo habla el lado RTR, para
ver qué hace el router con la lista. Las VRPs se leen del archivo en cada conexión.
"""
import argparse
import socket
import struct
import sys
import threading

SERIAL_QUERY, RESET_QUERY = 1, 2
CACHE_RESPONSE, IPV4_PREFIX, END_OF_DATA, CACHE_RESET, ERROR_REPORT = 3, 4, 7, 8, 10
SESSION_ID = 7007
SERIAL = 1
# Refresh, Retry y Expire, en segundos. Expire tiene un mínimo de 600 en el RFC.
REFRESH, RETRY, EXPIRE = 30, 5, 600


def read_vrps(path):
    """Cada línea: prefijo/largo maxlength as. Lo demás se ignora."""
    vrps = []
    with open(path, encoding="utf-8") as file:
        for line in file:
            line = line.split("#")[0].strip()
            if not line:
                continue
            prefix, max_length, asn = line.split()
            address, length = prefix.split("/")
            vrps.append((socket.inet_aton(address), int(length), int(max_length), int(asn)))
    return vrps


def ipv4_prefix_pdu(version, vrp):
    address, length, max_length, asn = vrp
    # Banderas = 1: anuncio.
    return struct.pack("!BBHIBBBB4sI", version, IPV4_PREFIX, 0, 20, 1, length, max_length, 0,
                       address, asn)


def end_of_data_pdu(version):
    if version == 0:
        return struct.pack("!BBHII", 0, END_OF_DATA, SESSION_ID, 12, SERIAL)
    return struct.pack("!BBHIIIII", version, END_OF_DATA, SESSION_ID, 24, SERIAL,
                       REFRESH, RETRY, EXPIRE)


def read_exact(conn, count):
    data = b""
    while len(data) < count:
        chunk = conn.recv(count - len(data))
        if not chunk:
            return None
        data += chunk
    return data


def send_table(conn, version, path):
    data = struct.pack("!BBHI", version, CACHE_RESPONSE, SESSION_ID, 8)
    for vrp in read_vrps(path):
        data += ipv4_prefix_pdu(version, vrp)
    data += end_of_data_pdu(version)
    conn.sendall(data)


def serve(conn, peer, path):
    with conn:
        while True:
            header = read_exact(conn, 8)
            if header is None:
                return
            version, kind, _, length = struct.unpack("!BBHI", header)
            if length < 8 or length > 4096:
                return
            if length > 8 and read_exact(conn, length - 8) is None:
                return
            print(f"{peer[0]}: PDU tipo {kind}, versión {version}", flush=True)
            if version > 1:
                # Código 4: versión no soportada. El router reintenta con una menor.
                conn.sendall(struct.pack("!BBHIII", 1, ERROR_REPORT, 4, 16, 0, 0))
                return
            if kind == RESET_QUERY:
                send_table(conn, version, path)
            elif kind == SERIAL_QUERY:
                # La lista es fija: no hay diferencias que entregar.
                conn.sendall(struct.pack("!BBHI", version, CACHE_RESPONSE, SESSION_ID, 8)
                             + end_of_data_pdu(version))


def main():
    parser = argparse.ArgumentParser(description="Cache RTR mínimo con una lista fija de VRPs")
    parser.add_argument("vrps")
    parser.add_argument("--port", type=int, default=8282)
    args = parser.parse_args()
    read_vrps(args.vrps)  # falla pronto si el archivo está mal
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("0.0.0.0", args.port))
    server.listen()
    while True:
        conn, peer = server.accept()
        threading.Thread(target=serve, args=(conn, peer, args.vrps), daemon=True).start()


if __name__ == "__main__":
    sys.exit(main())
bgp/cache/vrps-lax.txtDescargar bgp/cache/vrps-lax.txt
# El mismo ROA de la víctima, pero con largo máximo 25: autoriza más de lo que se anuncia.
100.64.1.0/24      24            64601
100.64.3.0/24      25            64603
bgp/cache/vrps.txtDescargar bgp/cache/vrps.txt
# prefijo          largo-máximo  AS autorizado
100.64.1.0/24      24            64601
100.64.3.0/24      24            64603
bgp/d/frr.confDescargar bgp/d/frr.conf
frr defaults traditional
hostname d
interface eth1
 ip address 10.207.1.2/30
interface eth2
 ip address 10.207.3.2/30
interface eth3
 ip address 10.207.10.1/30
router bgp 64640
 bgp router-id 10.207.0.2
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.1.1 remote-as 64630
 neighbor 10.207.1.1 description c (peer)
 neighbor 10.207.1.1 advertisement-interval 0
 neighbor 10.207.3.1 remote-as 64620
 neighbor 10.207.3.1 description b (cliente)
 neighbor 10.207.3.1 advertisement-interval 0
 address-family ipv4 unicast
 exit-address-family
bgp/r1/frr.confDescargar bgp/r1/frr.conf
frr defaults traditional
hostname r1
interface eth1
 ip address 100.64.1.1/24
interface eth2
 ip address 10.207.4.1/30
router bgp 64601
 bgp router-id 10.207.0.5
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.4.2 remote-as 64610
 neighbor 10.207.4.2 description a (proveedor)
 neighbor 10.207.4.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.1.0/24
 exit-address-family
bgp/r3/frr.confDescargar bgp/r3/frr.conf
frr defaults traditional
hostname r3
interface eth1
 ip address 100.64.3.1/24
interface eth2
 ip address 10.207.8.1/30
router bgp 64603
 bgp router-id 10.207.0.6
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.8.2 remote-as 64620
 neighbor 10.207.8.2 description b (proveedor)
 neighbor 10.207.8.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.3.0/24
 exit-address-family
bgp/r4/frr.confDescargar bgp/r4/frr.conf
frr defaults traditional
hostname r4
interface eth1
 ip address 10.207.6.1/30
interface eth2
 ip address 10.207.7.1/30
interface dum0
 ip address 100.64.4.1/24
router bgp 64604
 bgp router-id 10.207.0.7
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.6.2 remote-as 64610
 neighbor 10.207.6.2 description a (proveedor)
 neighbor 10.207.6.2 advertisement-interval 0
 neighbor 10.207.7.2 remote-as 64620
 neighbor 10.207.7.2 description b (proveedor)
 neighbor 10.207.7.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.4.0/24
  neighbor 10.207.6.2 route-map SOLO-PROPIAS out
  neighbor 10.207.7.2 route-map SOLO-PROPIAS out
 exit-address-family
! Regla de exportación de un cliente con dos proveedores: solo sale lo que origina
! el propio AS, es decir, las rutas con AS_PATH vacío.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
 match as-path PROPIAS
bgp/r6/frr.confDescargar bgp/r6/frr.conf
frr defaults traditional
hostname r6
interface eth1
 ip address 10.207.5.1/30
interface dum0
 ip address 100.64.66.1/24
router bgp 64666
 bgp router-id 10.207.0.8
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.207.5.2 remote-as 64610
 neighbor 10.207.5.2 description a (proveedor)
 neighbor 10.207.5.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.66.0/24
 exit-address-family
bgp/www/attacker.htmlDescargar bgp/www/attacker.html
ATACANTE: AS 64666 recibio esta conexion
bgp/www/server.htmlDescargar bgp/www/server.html
servidor legitimo de AS 64603
ixp/isp1/frr.confDescargar ixp/isp1/frr.conf
frr defaults traditional
hostname isp1
interface eth1
 ip address 100.64.71.1/24
interface eth2
 ip address 10.208.1.1/30
interface eth3
 ip address 10.208.100.1/24
router bgp 64701
 bgp router-id 10.208.0.1
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.208.1.2 remote-as 64710
 neighbor 10.208.1.2 description t (proveedor)
 neighbor 10.208.1.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.71.0/24
 exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
 match as-path PROPIAS
ixp/isp2/frr.confDescargar ixp/isp2/frr.conf
frr defaults traditional
hostname isp2
interface eth1
 ip address 100.64.72.1/24
interface eth2
 ip address 10.208.2.1/30
interface eth3
 ip address 10.208.100.2/24
router bgp 64702
 bgp router-id 10.208.0.2
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.208.2.2 remote-as 64710
 neighbor 10.208.2.2 description t (proveedor)
 neighbor 10.208.2.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.72.0/24
 exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
 match as-path PROPIAS
ixp/isp3/frr.confDescargar ixp/isp3/frr.conf
frr defaults traditional
hostname isp3
interface eth1
 ip address 100.64.73.1/24
interface eth2
 ip address 10.208.3.1/30
interface eth3
 ip address 10.208.100.3/24
router bgp 64703
 bgp router-id 10.208.0.3
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.208.3.2 remote-as 64710
 neighbor 10.208.3.2 description t (proveedor)
 neighbor 10.208.3.2 advertisement-interval 0
 address-family ipv4 unicast
  network 100.64.73.0/24
 exit-address-family
! Lo que un ISP le ofrece a un peer: solo lo que origina. La sesión se crea en el paso
! de peering; esta regla ya queda lista.
bgp as-path access-list PROPIAS seq 5 permit ^$
route-map SOLO-PROPIAS permit 10
 match as-path PROPIAS
ixp/t/frr.confDescargar ixp/t/frr.conf
frr defaults traditional
hostname t
interface eth1
 ip address 10.208.1.2/30
interface eth2
 ip address 10.208.2.2/30
interface eth3
 ip address 10.208.3.2/30
router bgp 64710
 bgp router-id 10.208.0.10
 no bgp ebgp-requires-policy
 timers bgp 3 9
 neighbor 10.208.1.1 remote-as 64701
 neighbor 10.208.1.1 description isp1 (cliente)
 neighbor 10.208.1.1 advertisement-interval 0
 neighbor 10.208.2.1 remote-as 64702
 neighbor 10.208.2.1 description isp2 (cliente)
 neighbor 10.208.2.1 advertisement-interval 0
 neighbor 10.208.3.1 remote-as 64703
 neighbor 10.208.3.1 description isp3 (cliente)
 neighbor 10.208.3.1 advertisement-interval 0

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

test_bgp.pyDescargar test_bgp.py
"""Pruebas de hijacks, fugas y defensas en bgp/bgp.clab.yml."""
import json
import os
import re
import subprocess
import unittest

import scenarios
from netlab import ANSWERS, HERE, answer_path, wait_until

LAB = scenarios.LAB
LEGIT = "servidor legitimo"
ATTACKER = "ATACANTE"
SERVER = scenarios.SERVER
VICTIM = scenarios.VICTIM
HALF = scenarios.HALVES[0]


def setUpModule():
    unittest.addModuleCleanup(LAB.destroy)
    LAB.deploy()
    if not wait_until(baseline, seconds=60):
        raise RuntimeError("la topología no quedó lista")


def web(node, address=SERVER):
    return LAB.out(node, "curl", "-s", "-m", "3", f"http://{address}:8080/")


def route(node, prefix):
    """(AS_PATH de la mejor ruta, estado RPKI), o None si no está en la tabla."""
    text = LAB.out(node, "vtysh", "-c", f"show bgp ipv4 unicast {prefix} json")
    try:
        for path in json.loads(text).get("paths", []):
            if path.get("bestpath", {}).get("overall"):
                return path["aspath"]["string"], path.get("rpkiValidationState", "")
    except ValueError:
        pass
    return None


def path_of(node, prefix):
    found = route(node, prefix)
    return found[0] if found else None


def origin(node, prefix):
    path = path_of(node, prefix)
    return path.split()[-1] if path else None


def trace(node, address):
    return re.findall(r"^\s*\d+\s+(\S+)", LAB.out(node, "traceroute", "-n", "-w", "1", address),
                      re.MULTILINE)


def baseline():
    return ("64630 64640 64620 64603" == path_of("a", VICTIM)
            and "64640 64630 64610 64601" == path_of("b", "100.64.1.0/24")
            and route("a", HALF) is None and route("d", HALF) is None
            and route("a", "100.64.4.0/24") is not None
            and route("b", "100.64.4.0/24") is not None
            and LEGIT in web("h1"))


class Scenario(unittest.TestCase):
    def setUp(self):
        self.assertTrue(wait_until(baseline, seconds=45), "no se volvió al estado inicial")
        self.addCleanup(scenarios.reset)

    def wait(self, predicate, seconds=25):
        self.assertTrue(wait_until(predicate, seconds=seconds), "no ocurrió a tiempo")


class A_Baseline(Scenario):
    def test_host_reaches_the_server_along_the_hierarchy(self):
        self.assertIn(LEGIT, web("h1"))
        self.assertEqual(trace("h1", SERVER), ["100.64.1.1", "10.207.4.2", "10.207.2.2",
                                               "10.207.1.2", "10.207.3.1", "10.207.8.1", SERVER])

    def test_multihomed_customer_exports_only_its_own_prefix(self):
        self.assertNotIn("64604", path_of("b", "100.64.1.0/24"))
        self.assertNotIn("64604", path_of("a", VICTIM))


class B_SamePrefix(Scenario):
    def test_some_ases_are_fooled_and_others_are_not(self):
        scenarios.same()
        self.wait(lambda: origin("r1", VICTIM) == "64666")
        self.assertEqual(path_of("a", VICTIM), "64666")
        self.assertEqual(path_of("c", VICTIM), "64610 64666")
        # Del otro lado de la cima, el camino legítimo sigue siendo más corto.
        self.assertEqual(path_of("d", VICTIM), "64620 64603")
        self.assertEqual(path_of("b", VICTIM), "64603")
        self.assertIn(ATTACKER, web("h1"))
        self.assertEqual(trace("h1", SERVER)[-1], SERVER)
        self.assertEqual(len(trace("h1", SERVER)), 3)

    def test_withdrawing_heals_the_routing(self):
        scenarios.same()
        self.wait(lambda: ATTACKER in web("h1"))
        scenarios.reset()
        self.wait(lambda: LEGIT in web("h1"))


class C_MoreSpecific(Scenario):
    def test_everyone_goes_to_the_attacker_for_the_covered_half(self):
        scenarios.specific()
        self.wait(lambda: route("r3", HALF) is not None)
        for node in ("r1", "a", "c", "d", "b", "r4", "r3"):
            with self.subTest(node=node):
                self.assertEqual(origin(node, HALF), "64666")
                self.assertIn("100.64.3.0/25", LAB.out(node, "vtysh", "-c",
                                                       f"show ip route {SERVER}"))
        self.assertIn(ATTACKER, web("h1"))

    def test_the_other_half_still_reaches_the_victim(self):
        scenarios.specific()
        self.wait(lambda: ATTACKER in web("h1"))
        self.assertIn(LEGIT, web("h1", "100.64.3.200"))

    def test_victim_can_win_back_with_longer_prefixes(self):
        scenarios.same()
        self.wait(lambda: ATTACKER in web("h1"))
        scenarios.counter()
        self.wait(lambda: LEGIT in web("h1"))
        self.assertEqual(origin("r1", HALF), "64603")
        self.assertEqual(origin("r1", VICTIM), "64666")


class D_Leak(Scenario):
    def test_leak_makes_the_customer_carry_traffic_between_its_providers(self):
        scenarios.leak()
        self.wait(lambda: path_of("b", "100.64.1.0/24") == "64604 64610 64601")
        self.assertEqual(path_of("a", VICTIM), "64604 64620 64603")
        self.assertIn("10.207.7.1", trace("srv", "100.64.1.10"))
        self.assertIn("10.207.6.1", trace("h1", SERVER))

    def test_leak_adds_the_delay_of_the_small_network(self):
        scenarios.slow_r4()
        scenarios.leak()
        self.wait(lambda: path_of("b", "100.64.1.0/24") == "64604 64610 64601")
        output = LAB.out("srv", "ping", "-c", "5", "-i", "0.2", "100.64.1.10")
        average = float(re.search(r"= [\d.]+/([\d.]+)/", output).group(1))
        self.assertGreater(average, 8)

    def test_max_prefix_closes_the_session_with_the_leaker(self):
        scenarios.max_prefix()
        scenarios.leak()
        self.wait(lambda: "Idle (PfxCt)" in LAB.out("b", "vtysh", "-c", "show bgp summary"))
        self.wait(lambda: path_of("b", "100.64.1.0/24") == "64640 64630 64610 64601")
        # El precio: b ya no aprende la red de r4 por esa sesión, hasta que alguien la reabra.
        self.assertEqual(path_of("b", "100.64.4.0/24"), "64640 64630 64610 64604")


class E_Rov(Scenario):
    def rov_state(self, node, prefix):
        found = route(node, prefix)
        return found[1] if found else None

    def wait_table(self, node):
        self.wait(lambda: "Number of IPv4 Prefixes: 2"
                  in LAB.out(node, "vtysh", "-c", "show rpki prefix-table"))

    def test_invalid_is_dropped_only_when_policy_says_so(self):
        # Sin la regla, el router marca la ruta pero la sigue usando (RFC 6811, sec. 2).
        scenarios.conf("a", "rpki", "rpki polling_period 5",
                       "rpki cache tcp 10.207.9.2 8282 preference 1")
        self.wait_table("a")
        scenarios.same()
        self.wait(lambda: self.rov_state("a", VICTIM) == "invalid")
        self.assertEqual(path_of("a", VICTIM), "64666")
        self.assertIn(ATTACKER, web("h1"))
        scenarios.rov_a()
        self.wait(lambda: self.rov_state("a", VICTIM) == "valid")
        self.assertEqual(origin("a", VICTIM), "64603")
        self.wait(lambda: LEGIT in web("h1"))

    def test_rov_matches_the_classifier(self):
        scenarios.conf("a", "rpki", "rpki polling_period 5",
                       "rpki cache tcp 10.207.9.2 8282 preference 1")
        self.wait_table("a")
        scenarios.specific()
        self.wait(lambda: route("a", HALF) is not None)
        vrps = os.path.join(HERE, "bgp", "cache", "vrps.txt")
        for prefix, asn, state in ((HALF, 64666, "invalid"), ("100.64.1.0/24", 64601, "valid"),
                                   ("100.64.66.0/24", 64666, "not found")):
            with self.subTest(prefix=prefix):
                self.wait(lambda: self.rov_state("a", prefix) == state.replace("not found", "notfound")
                          or self.rov_state("a", prefix) == state)
                done = subprocess.run([os.path.join(HERE, "rov"), vrps, prefix, str(asn)],
                                      capture_output=True, text=True, check=True)
                self.assertEqual(done.stdout.strip().splitlines()[-1], state)

    def test_more_specific_is_invalid_by_max_length(self):
        scenarios.rov_a()
        self.wait_table("a")
        scenarios.specific()
        self.wait(lambda: route("r6", HALF) is not None)
        self.assertFalse(wait_until(lambda: route("a", HALF) is not None, seconds=4))
        self.assertIn(LEGIT, web("h1"))

    def test_forged_origin_passes_rov(self):
        scenarios.rov_a()
        self.wait_table("a")
        scenarios.forged()
        self.wait(lambda: path_of("a", VICTIM) == "64666 64603")
        self.assertEqual(self.rov_state("a", VICTIM), "valid")
        self.assertIn(ATTACKER, web("h1"))

    def test_forged_more_specific_depends_on_the_max_length_of_the_roa(self):
        scenarios.rov_a()
        self.wait_table("a")
        scenarios.forged_specific()
        self.wait(lambda: route("r6", HALF) is not None)
        self.assertFalse(wait_until(lambda: route("a", HALF) is not None, seconds=4))
        self.assertIn(LEGIT, web("h1"))

    def test_lax_max_length_lets_the_forged_more_specific_through(self):
        scenarios.rov_a_lax()
        self.wait_table("a")
        self.wait(lambda: "24 -  25" in LAB.out("a", "vtysh", "-c", "show rpki prefix-table"))
        scenarios.forged_specific()
        self.wait(lambda: path_of("a", HALF) == "64666 64603")
        self.assertEqual(self.rov_state("a", HALF), "valid")
        self.assertIn(ATTACKER, web("h1"))

    def test_partial_deployment_protects_only_those_behind_it(self):
        scenarios.rov_d()
        self.wait_table("d")
        scenarios.specific()
        self.wait(lambda: route("c", HALF) is not None)
        self.assertFalse(wait_until(lambda: route("d", HALF) is not None, seconds=4))
        self.assertIsNone(route("b", HALF))
        self.assertIn(ATTACKER, web("h1"))

    def test_rov_does_not_stop_a_leak(self):
        scenarios.rov_a()
        scenarios.rov_d()
        self.wait_table("a")
        self.wait_table("d")
        scenarios.leak()
        self.wait(lambda: path_of("a", VICTIM) == "64604 64620 64603")
        self.assertEqual(self.rov_state("a", VICTIM), "valid")


class F_Filters(Scenario):
    """Paso 5 del laboratorio. Se salta hasta que completen lab/filters-*.conf."""

    def setUp(self):
        answer_path("filters-a.conf")
        answer_path("filters-b.conf")
        super().setUp()
        scenarios.filters(ANSWERS)
        self.assertTrue(wait_until(baseline, seconds=30), "los filtros rompieron rutas legítimas")

    def assert_attacker_kept_out(self):
        self.assertFalse(wait_until(lambda: ATTACKER in web("h1"), seconds=4))
        self.assertEqual(origin("a", VICTIM), "64603")
        self.assertIsNone(route("a", HALF))
        self.assertIn(LEGIT, web("h1"))

    def test_legitimate_prefixes_still_pass(self):
        for node, prefix, asn in (("a", "100.64.1.0/24", "64601"), ("a", "100.64.66.0/24", "64666"),
                                  ("a", "100.64.4.0/24", "64604"), ("b", "100.64.3.0/24", "64603"),
                                  ("b", "100.64.4.0/24", "64604")):
            with self.subTest(node=node, prefix=prefix):
                self.assertEqual(origin(node, prefix), asn)

    def test_same_prefix_is_rejected(self):
        scenarios.same()
        self.assertTrue(wait_until(lambda: route("r6", VICTIM) is not None))
        self.assert_attacker_kept_out()

    def test_more_specific_is_rejected(self):
        scenarios.specific()
        self.assertTrue(wait_until(lambda: route("r6", HALF) is not None))
        self.assert_attacker_kept_out()

    def test_forged_origin_is_rejected(self):
        scenarios.forged()
        self.assertTrue(wait_until(lambda: route("r6", VICTIM) is not None))
        self.assert_attacker_kept_out()

    def test_more_specific_of_the_own_block_is_rejected(self):
        scenarios.conf("r6", "ip route 100.64.66.0/25 Null0",
                       *scenarios.in_bgp(64666, "network 100.64.66.0/25"))
        self.addCleanup(lambda: (scenarios.conf("r6", *scenarios.in_bgp(64666, "no network 100.64.66.0/25")),
                                 scenarios.conf("r6", "no ip route 100.64.66.0/25 Null0")))
        self.assertTrue(wait_until(lambda: route("r6", "100.64.66.0/25") is not None))
        self.assertFalse(wait_until(lambda: route("a", "100.64.66.0/25") is not None, seconds=4))

    def test_leak_is_rejected_on_both_sides(self):
        scenarios.leak()
        self.assertFalse(wait_until(lambda: "64604" in (path_of("b", "100.64.1.0/24") or ""),
                                    seconds=4))
        self.assertNotIn("64604", path_of("a", VICTIM))
        self.assertNotIn("10.207.7.1", trace("srv", "100.64.1.10"))


if __name__ == "__main__":
    unittest.main()
test_ixp.pyDescargar test_ixp.py
"""Pruebas del peering por el IXP en ixp/ixp.clab.yml."""
import os
import re
import subprocess
import time
import unittest

from netlab import HERE, Lab, wait_until

LAB = Lab("ixp", "w07-ixp", "redes-w07-ixp")
H2, H3 = "100.64.72.10", "100.64.73.10"
PEERING = os.path.join(HERE, "ixp", "peering.sh")


def setUpModule():
    unittest.addModuleCleanup(LAB.destroy)
    LAB.deploy()
    if not wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0
                      and LAB.sh("h1", "ping", "-c", "1", "-W", "2", H3).returncode == 0, seconds=60):
        raise RuntimeError("la topología no quedó lista")


def peering(state):
    done = subprocess.run(["sh", PEERING, state], capture_output=True, text=True)
    if done.returncode != 0:
        raise RuntimeError(done.stdout + done.stderr)


def rtt(node, address):
    output = LAB.out(node, "ping", "-c", "5", "-i", "0.2", address)
    return float(re.search(r"= [\d.]+/([\d.]+)/", output).group(1))


def path_to(node, address):
    return LAB.out(node, "traceroute", "-n", "-w", "1", address)


def transit_bytes():
    text = LAB.out("t", "cat", "/sys/class/net/eth1/statistics/rx_bytes")
    return int(text.strip())


def send_mbytes(megabytes=5):
    LAB.daemon("h2", "iperf3 -s -1 >/dev/null 2>&1")
    time.sleep(0.5)
    before = transit_bytes()
    done = LAB.sh("h1", "iperf3", "-c", H2, "-n", f"{megabytes}M", timeout=120)
    if done.returncode != 0:
        raise RuntimeError(done.stdout + done.stderr)
    time.sleep(1)  # lo último que se envió todavía viene en camino por el enlace con retardo
    return transit_bytes() - before


def peer_routes():
    return "100.64.72.0/24" in LAB.out("isp1", "ip", "route", "get", H2) \
        or "10.208.100.2" in LAB.out("isp1", "ip", "route", "get", H2)


class Peering(unittest.TestCase):
    def setUp(self):
        peering("off")
        self.addCleanup(peering, "off")
        self.assertTrue(wait_until(lambda: not peer_routes(), seconds=20))
        self.assertTrue(wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0))

    def test_without_peering_local_traffic_goes_through_the_far_provider(self):
        self.assertIn("10.208.1.2", path_to("h1", H2))
        self.assertGreater(rtt("h1", H2), 120)

    def test_peering_shortens_the_path_and_keeps_the_rest(self):
        peering("on")
        self.assertTrue(wait_until(peer_routes, seconds=30))
        path = path_to("h1", H2)
        self.assertNotIn("10.208.1.2", path)
        self.assertIn("10.208.100.2", path)
        self.assertLess(rtt("h1", H2), 40)
        # isp3 no hizo peering: con él sigue todo igual.
        self.assertIn("10.208.1.2", path_to("h1", H3))
        self.assertGreater(rtt("h1", H3), 120)

    def test_traffic_leaves_the_paid_link(self):
        before = send_mbytes()
        self.assertGreater(before, 4_000_000)
        peering("on")
        self.assertTrue(wait_until(peer_routes, seconds=30))
        self.assertLess(send_mbytes(), 500_000)

    def test_peer_does_not_get_free_transit(self):
        peering("on")
        self.assertTrue(wait_until(peer_routes, seconds=30))
        offered = LAB.out("isp1", "vtysh", "-c",
                          "show bgp ipv4 unicast neighbors 10.208.100.2 advertised-routes")
        self.assertIn("100.64.71.0/24", offered)
        self.assertNotIn("100.64.73.0/24", offered)

    def test_if_the_ixp_fails_traffic_returns_to_transit(self):
        peering("on")
        self.assertTrue(wait_until(peer_routes, seconds=30))
        self.addCleanup(LAB.out, "ix", "ip", "link", "set", "eth2", "up")
        LAB.out("ix", "ip", "link", "set", "eth2", "down")
        self.assertTrue(wait_until(lambda: not peer_routes(), seconds=25))
        self.assertTrue(wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2", H2).returncode == 0))
        self.assertGreater(rtt("h1", H2), 120)
        LAB.out("ix", "ip", "link", "set", "eth2", "up")
        self.assertTrue(wait_until(peer_routes, seconds=40))
        self.assertLess(rtt("h1", H2), 40)


if __name__ == "__main__":
    unittest.main()
test_rov.pyDescargar test_rov.py
"""Pruebas de rov.c contra los casos del RFC 6811, y del cache RTR contra el RFC 8210.

Ninguna necesita una topología.
"""
import os
import socket
import struct
import subprocess
import sys
import time
import unittest

from netlab import HERE

EXAMPLE = os.path.join(HERE, "vrps-example.txt")


def rov(prefix, asn, vrps=EXAMPLE):
    done = subprocess.run([os.path.join(HERE, "rov"), vrps, prefix, str(asn)],
                          capture_output=True, text=True)
    return done.returncode, done.stdout.strip().splitlines()[-1] if done.stdout else ""


class Classification(unittest.TestCase):
    def test_guide_cases(self):
        # Un ROA autoriza al AS 64600 a originar 198.51.100.0/24 con largo máximo 24.
        for prefix, asn, state in (("198.51.100.0/24", 64600, "valid"),
                                   ("198.51.100.0/24", 64666, "invalid"),
                                   ("198.51.100.0/25", 64600, "invalid"),
                                   ("198.51.100.0/25", 64666, "invalid"),
                                   ("198.51.0.0/16", 64600, "not found"),
                                   ("198.51.101.0/24", 64600, "not found")):
            with self.subTest(prefix=prefix, asn=asn):
                self.assertEqual(rov(prefix, asn), (0, state))

    def test_max_length_allows_more_specifics_up_to_it(self):
        self.assertEqual(rov("203.0.113.128/25", 64601), (0, "valid"))
        self.assertEqual(rov("203.0.113.128/26", 64601), (0, "invalid"))

    def test_as_zero_never_matches(self):
        self.assertEqual(rov("192.0.2.0/24", 0), (0, "invalid"))

    def test_one_matching_vrp_is_enough(self):
        self.assertEqual(rov("10.0.0.0/8", 64701), (0, "valid"))
        self.assertEqual(rov("10.1.0.0/16", 64700), (0, "valid"))
        # La segunda VRP cubre el /16, pero no lo autoriza: basta con la primera.
        self.assertEqual(rov("10.1.0.0/16", 64701), (0, "invalid"))

    def test_bad_arguments_are_rejected(self):
        for prefix, asn in (("10.0.0.0", 1), ("10.0.0.0/33", 1), ("x/8", 1), ("10.0.0.0/8", "y")):
            with self.subTest(prefix=prefix, asn=asn):
                self.assertEqual(rov(prefix, asn)[0], 2)


class RtrCache(unittest.TestCase):
    """Un router que habla RTR a mano: mismos bytes que manda FRRouting."""

    def setUp(self):
        with socket.socket() as probe:
            probe.bind(("127.0.0.1", 0))
            self.port = probe.getsockname()[1]
        self.cache = subprocess.Popen(
            [sys.executable, os.path.join(HERE, "bgp", "cache", "rtr_cache.py"),
             os.path.join(HERE, "bgp", "cache", "vrps.txt"), "--port", str(self.port)],
            stdout=subprocess.DEVNULL)
        self.addCleanup(self.cache.wait)
        self.addCleanup(self.cache.kill)
        deadline = time.monotonic() + 5
        while True:
            try:
                self.conn = socket.create_connection(("127.0.0.1", self.port), timeout=3)
                break
            except OSError:
                if time.monotonic() > deadline:
                    raise
                time.sleep(0.05)
        self.addCleanup(self.conn.close)

    def read_pdu(self):
        data = b""
        while len(data) < 8:
            data += self.conn.recv(8 - len(data))
        version, kind, field, length = struct.unpack("!BBHI", data)
        while len(data) < length:
            data += self.conn.recv(length - len(data))
        return version, kind, field, data

    def test_reset_query_gets_the_whole_table(self):
        self.conn.sendall(struct.pack("!BBHI", 1, 2, 0, 8))
        version, kind, session, _ = self.read_pdu()
        self.assertEqual((version, kind), (1, 3))
        prefixes = []
        while True:
            _, kind, field, data = self.read_pdu()
            if kind == 7:
                break
            self.assertEqual((kind, len(data)), (4, 20))
            _, length, max_length, _, address, asn = struct.unpack("!BBBB4sI", data[8:])
            prefixes.append((socket.inet_ntoa(address), length, max_length, asn))
        self.assertEqual(field, session)
        self.assertEqual(len(data), 24)
        self.assertIn(("100.64.3.0", 24, 24, 64603), prefixes)
        self.assertIn(("100.64.1.0", 24, 24, 64601), prefixes)

    def test_version_zero_is_answered_in_version_zero(self):
        self.conn.sendall(struct.pack("!BBHI", 0, 2, 0, 8))
        version = kind = None
        while kind != 7:
            version, kind, _, data = self.read_pdu()
            self.assertEqual(version, 0)
        self.assertEqual(len(data), 12)

    def test_unknown_version_gets_an_error_report(self):
        self.conn.sendall(struct.pack("!BBHI", 9, 2, 0, 8))
        _, kind, code, _ = self.read_pdu()
        self.assertEqual((kind, code), (10, 4))


if __name__ == "__main__":
    unittest.main()

Laboratorio

Archivos de partida del laboratorio de la semana.

lab/filters-a.confDescargar lab/filters-a.conf
! Filtros de a (AS 64610), en sintaxis de vtysh: el archivo se carga con vtysh -f.
! Paso 5 del laboratorio: que a acepte de cada cliente solo lo que le corresponde.
!
!   cliente   sesión       bloque asignado    nombre de la lista
!   r1        10.207.4.1   100.64.1.0/24      CLIENTE-R1
!   r4        10.207.6.1   100.64.4.0/24      CLIENTE-R4
!   r6        10.207.5.1   100.64.66.0/24     CLIENTE-R6
!
! Usen esos nombres: `scenarios.py reset` los conoce y los borra.
! TODO: escriban las listas y aplíquenlas de entrada a cada sesión de cliente.
lab/filters-b.confDescargar lab/filters-b.conf
! Filtros de b (AS 64620), en sintaxis de vtysh: el archivo se carga con vtysh -f.
!
!   cliente   sesión       bloque asignado    nombre de la lista
!   r3        10.207.8.1   100.64.3.0/24      CLIENTE-R3
!   r4        10.207.7.1   100.64.4.0/24      CLIENTE-R4
!
! TODO: lo mismo que en filters-a.conf.

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 7: los problemas de BGP y la estructura de Internet. Se usa dentro del
# contenedor del curso.
#
#   make              compila rov
#   make test         compila, levanta las dos topologías una tras otra y corre las
#                     pruebas (test_*.py); siempre destruye lo que levantó. La prueba
#                     de los filtros usa lab/ y se salta hasta que la completen
#   make up           levanta los seis AS con el atacante (bgp/bgp.clab.yml)
#   make up-ixp       levanta los tres ISP con el IXP (ixp/ixp.clab.yml)
#   make down         destruye las dos topologías y sus redes de Docker
#   make clean        lo anterior, y borra los binarios

CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -D_DEFAULT_SOURCE -O2 -g

.PHONY: all test up up-ixp down clean

all: rov

rov: rov.c
	$(CC) $(CFLAGS) -o $@ $<

test: all
	@status=0; PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -p 'test_*.py' -v || status=$$?; \
	  $(MAKE) --no-print-directory down; exit $$status

up:
	cd bgp && containerlab deploy -t bgp.clab.yml --reconfigure

up-ixp:
	cd ixp && containerlab deploy -t ixp.clab.yml --reconfigure

down:
	@cd bgp && containerlab destroy -t bgp.clab.yml --cleanup >/dev/null 2>&1 || true
	@cd ixp && containerlab destroy -t ixp.clab.yml --cleanup >/dev/null 2>&1 || true
	@docker network rm redes-w07-bgp >/dev/null 2>&1 || true
	@docker network rm redes-w07-ixp >/dev/null 2>&1 || true

clean: down
	rm -f rov
	rm -rf __pycache__ bgp/clab-w07-bgp ixp/clab-w07-ixp