Semana 4 — IP, forwarding y NAT

Objetivos

  • Distinguir el plano de datos del plano de control.
  • Leer un datagrama IPv4 y explicar sus campos.
  • Dividir una red con CIDR y aplicar longest prefix match.
  • Explicar qué hay dentro de un router y dónde se forman las colas.
  • Explicar ICMP, DHCP y NAT, y qué cambia con IPv6.

Sesión A — IP: datagramas, CIDR y forwarding · Dentro de un router

Lecturas:

  • Kurose y Ross, secs. 4.1 (Overview of Network Layer), 4.2 (What’s Inside a Router?), 4.3.1 (IPv4 Datagram Format) y la parte de CIDR de 4.3.2 (IPv4 Addressing).
  • CS168, IP Header, Addressing y Router Hardware.

Lecturas complementarias:

Temas:

  • Forwarding y routing: uno es local y rápido, el otro es global y más lento.
  • Campos de IPv4: TTL, protocolo, checksum, direcciones y fragmentación.
  • Direccionamiento: subredes, máscaras y CIDR. Ejemplo resuelto: dividir 10.20.0.0/22 en cuatro subredes.
  • Agregación de rutas y longest prefix match. Ejemplo con una tabla de cuatro entradas.
  • Error común: elegir la ruta “más general” o la primera de la tabla. Siempre gana el prefijo más largo.
  • Dentro de un router: puertos de entrada, fabric de conmutación, puertos de salida y plano de control. Colas y scheduling.

Sesión B — ICMP, DHCP, NAT e IPv6

Lecturas:

  • Kurose y Ross, secs. 4.3.2 (DHCP), 4.3.3 (Network Address Translation), 4.3.4 (IPv6) y 5.6 (ICMP).
  • CS168, DHCP y NAT.

Lecturas complementarias:

Temas:

  • ICMP: echo, destino inalcanzable y TTL excedido. Cómo funciona traceroute, que ya usaron en P0.
  • DHCP: discover, offer, request y ack. Por qué se envía por broadcast.
  • NAT: la tabla de traducción, direcciones privadas y cómo complica recibir conexiones entrantes.
  • Por qué se acabaron las direcciones IPv4. IPv6: direcciones de 128 bits, encabezado fijo y sin fragmentación en routers.
  • Convivencia de IPv4 e IPv6: doble pila y túneles.
  • Recorrido completo de un paquete, de un host a otro, con cada traducción y cada encabezado.

Laboratorio — Subredes y NAT con Containerlab

Trabajen en code/week-04/.

  1. Levantar una topología de tres routers con rutas estáticas. Agregar las rutas que faltan hasta que todos los hosts se alcancen.
  2. Agregar a un router una ruta más específica para un destino y predecir qué camino se usa. Comprobarlo con ip route get y traceroute.
  3. Usar tcpdump en cada salto y comparar TTL, direcciones MAC y direcciones IP. Explicar qué cambia en cada salto.
  4. Configurar NAT con nftables en el router de salida. Capturar el mismo paquete a cada lado del router y leer las reglas con nft list ruleset y la tabla de traducción con conntrack -L.
  5. Intentar abrir una conexión desde afuera hacia un host privado, y luego agregar una redirección de puerto.
  6. Asignar direcciones IPv6 de ejemplo (2001:db8::/32) y hacer ping por IPv6.
  7. Avanzar con P1.

Preguntas de repaso

  1. Dividan 192.168.8.0/24 en subredes para 100, 50 y 20 hosts. Escriban prefijo, máscara y rango de cada una.
  2. Con las rutas 10.0.0.0/8, 10.1.0.0/16, 10.1.2.0/24 y 0.0.0.0/0, ¿por dónde sale 10.1.2.3? ¿Y 10.2.0.1?
  3. Un paquete cruza tres routers. ¿Qué campos de los encabezados de enlace e IP cambian en cada salto?
  4. ¿Por qué traceroute necesita ICMP? ¿Qué pasa si un router no lo envía?
  5. Dos hosts detrás de un NAT abren una conexión al mismo servidor, desde el mismo puerto local. ¿Cómo los distingue el NAT?

Código de la semana

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

Ejemplos (16)Pruebas (3)Laboratorio (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

cidr.cDescargar cidr.c
// cidr.c — calcula los datos de un prefijo IPv4 y lo divide en subredes iguales.
//
//   ./cidr 10.20.0.0/22       muestra máscara, red, broadcast y rango de hosts
//   ./cidr 10.20.0.0/22 4     lo divide en 4 subredes (una potencia de 2)
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <errno.h>
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Máscara de un prefijo de largo len. El caso 0 va aparte porque desplazar un
// entero de 32 bits en 32 posiciones no está definido en C.
static uint32_t mask_of(int len) {
    return len == 0 ? 0 : UINT32_MAX << (32 - len);
}

// Convierte "a.b.c.d/n" a una dirección en orden de host y un largo.
static int parse_prefix(const char *text, uint32_t *addr, int *len) {
    char buffer[32];
    if (strlen(text) >= sizeof buffer) {
        return -1;
    }
    strcpy(buffer, text);

    char *slash = strchr(buffer, '/');
    if (slash == NULL) {
        return -1;
    }
    *slash = '\0';

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

    struct in_addr parsed;
    if (inet_pton(AF_INET, buffer, &parsed) != 1) {
        return -1;
    }
    *addr = ntohl(parsed.s_addr);
    *len = (int)value;
    return 0;
}

// Escribe una dirección en orden de host como texto. Devuelve un buffer estático
// distinto en cada una de las cuatro llamadas más recientes.
static const char *ip_text(uint32_t addr) {
    static char buffers[4][INET_ADDRSTRLEN];
    static int next = 0;
    char *out = buffers[next];
    next = (next + 1) % 4;

    struct in_addr raw = {.s_addr = htonl(addr)};
    if (inet_ntop(AF_INET, &raw, out, INET_ADDRSTRLEN) == NULL) {
        perror("inet_ntop");
        exit(1);
    }
    return out;
}

// Direcciones que se pueden asignar a hosts. En /31 (RFC 3021) son las dos, y en
// /32 es la única; en lo demás se restan la de red y la de broadcast.
static uint64_t host_count(int len) {
    if (len == 32) {
        return 1;
    }
    if (len == 31) {
        return 2;
    }
    return (UINT64_C(1) << (32 - len)) - 2;
}

static void print_info(uint32_t addr, int len) {
    uint32_t mask = mask_of(len);
    uint32_t network = addr & mask;
    uint32_t broadcast = network | ~mask;
    uint32_t first = len >= 31 ? network : network + 1;
    uint32_t last = len >= 31 ? broadcast : broadcast - 1;

    if (addr != network) {
        printf("aviso      %s tiene bits de host encendidos\n", ip_text(addr));
    }
    printf("prefijo    %s/%d\n", ip_text(network), len);
    printf("máscara    %s\n", ip_text(mask));
    printf("total      %" PRIu64 " direcciones\n", UINT64_C(1) << (32 - len));
    if (len < 31) {
        printf("broadcast  %s\n", ip_text(broadcast));
    }
    printf("hosts      %s - %s (%" PRIu64 ")\n", ip_text(first), ip_text(last),
           host_count(len));
}

static int split(uint32_t addr, int len, long parts) {
    if (parts > (1L << 32)) {
        fprintf(stderr, "cidr: /%d no alcanza para %ld subredes\n", len, parts);
        return 1;
    }
    int extra = 0;
    while ((1L << extra) < parts) {
        extra++;
    }
    if ((1L << extra) != parts) {
        fprintf(stderr, "cidr: %ld no es una potencia de 2\n", parts);
        return 1;
    }
    if (len + extra > 32) {
        fprintf(stderr, "cidr: /%d no alcanza para %ld subredes\n", len, parts);
        return 1;
    }

    int new_len = len + extra;
    uint32_t network = addr & mask_of(len);
    uint64_t step = UINT64_C(1) << (32 - new_len);
    printf("%s/%d en %ld subredes de /%d (%" PRIu64 " direcciones cada una):\n",
           ip_text(network), len, parts, new_len, step);
    for (long i = 0; i < parts; i++) {
        uint32_t sub = network + (uint32_t)((uint64_t)i * step);
        uint32_t broadcast = sub | ~mask_of(new_len);
        uint32_t first = new_len >= 31 ? sub : sub + 1;
        uint32_t last = new_len >= 31 ? broadcast : broadcast - 1;
        char label[32];
        int written = snprintf(label, sizeof label, "%s/%d", ip_text(sub), new_len);
        if (written < 0 || (size_t)written >= sizeof label) {
            fprintf(stderr, "cidr: error al formatear la subred\n");
            return 1;
        }
        printf("  %-18s hosts %s - %s (%" PRIu64 ")\n", label, ip_text(first), ip_text(last),
               host_count(new_len));
    }
    return 0;
}

int main(int argc, char *argv[]) {
    if (argc != 2 && argc != 3) {
        fprintf(stderr, "uso: %s a.b.c.d/n [subredes]\n", argv[0]);
        return 2;
    }

    uint32_t addr;
    int len;
    if (parse_prefix(argv[1], &addr, &len) != 0) {
        fprintf(stderr, "cidr: prefijo inválido: %s\n", argv[1]);
        return 2;
    }

    if (argc == 2) {
        print_info(addr, len);
        return 0;
    }

    char *end;
    errno = 0;
    long parts = strtol(argv[2], &end, 10);
    if (errno != 0 || end == argv[2] || *end != '\0' || parts < 1) {
        fprintf(stderr, "cidr: número de subredes inválido: %s\n", argv[2]);
        return 2;
    }
    return split(addr, len, parts);
}
ipv4_header.cDescargar ipv4_header.c
// ipv4_header.c — lee un paquete de la salida de "tcpdump -x" y muestra los
// campos de su encabezado IPv4, byte a byte. Se usa así:
//
//   docker exec clab-w04-fwd-r1 tcpdump -l -n -x -c 1 -i eth3 icmp | ./ipv4_header
//
// Solo mira el primer paquete que recibe.
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>

#define HEADER_MIN 20
#define MAX_BYTES 65535

static int hex_value(char c) {
    if (c >= '0' && c <= '9') {
        return c - '0';
    }
    if (c >= 'a' && c <= 'f') {
        return c - 'a' + 10;
    }
    if (c >= 'A' && c <= 'F') {
        return c - 'A' + 10;
    }
    return -1;
}

// Agrega a bytes lo que hay después de "0xNNNN:" en una línea de tcpdump -x.
// Devuelve cuántos bytes hay en total; las líneas sin ese inicio no cuentan.
static size_t read_hex_line(const char *line, uint8_t *bytes, size_t count) {
    const char *p = line;
    while (isspace((unsigned char)*p)) {
        p++;
    }
    if (strncmp(p, "0x", 2) != 0) {
        return count;
    }
    p = strchr(p, ':');
    if (p == NULL) {
        return count;
    }
    p++;

    while (*p != '\0' && count < MAX_BYTES) {
        if (isspace((unsigned char)*p)) {
            p++;
            continue;
        }
        int high = hex_value(p[0]);
        int low = high < 0 ? -1 : hex_value(p[1]);
        if (low < 0) {
            break;
        }
        bytes[count++] = (uint8_t)(high * 16 + low);
        p += 2;
    }
    return count;
}

static const char *protocol_name(uint8_t protocol) {
    switch (protocol) {
    case 1:
        return "ICMP";
    case 6:
        return "TCP";
    case 17:
        return "UDP";
    default:
        return "otro";
    }
}

static uint16_t read_u16(const uint8_t *p) {
    return (uint16_t)((p[0] << 8) | p[1]);
}

static void print_address(const char *label, const uint8_t *p) {
    char text[INET_ADDRSTRLEN];
    if (inet_ntop(AF_INET, p, text, sizeof text) == NULL) {
        perror("inet_ntop");
        return;
    }
    printf("%-12s %s\n", label, text);
}

int main(void) {
    static uint8_t bytes[MAX_BYTES];
    size_t count = 0;
    bool started = false;
    char line[256];

    while (fgets(line, sizeof line, stdin) != NULL) {
        size_t before = count;
        count = read_hex_line(line, bytes, count);
        if (count > before) {
            started = true;
        } else if (started) {
            break;  // terminó el primer paquete
        }
    }
    if (ferror(stdin)) {
        perror("stdin");
        return 1;
    }
    if (count < HEADER_MIN) {
        fprintf(stderr, "ipv4_header: se leyeron %zu bytes; faltan datos\n", count);
        return 1;
    }

    unsigned version = bytes[0] >> 4;
    unsigned header_length = (unsigned)(bytes[0] & 0x0f) * 4;
    if (version != 4 || header_length < HEADER_MIN || header_length > count) {
        fprintf(stderr, "ipv4_header: no es un encabezado IPv4 válido\n");
        return 1;
    }

    uint16_t flags_offset = read_u16(&bytes[6]);
    unsigned offset_units = flags_offset & 0x1fff;

    printf("versión      %u\n", version);
    printf("%-12s %u bytes\n", "encabezado", header_length);
    printf("%-12s DSCP %u, ECN %u\n", "ToS", (unsigned)(bytes[1] >> 2), (unsigned)(bytes[1] & 3));
    printf("%-12s %u bytes\n", "largo total", read_u16(&bytes[2]));
    printf("%-12s 0x%04x\n", "id", read_u16(&bytes[4]));
    printf("%-12s DF=%u MF=%u\n", "flags", (flags_offset >> 14) & 1u, (flags_offset >> 13) & 1u);
    printf("%-12s %u (%u bytes)\n", "offset", offset_units, offset_units * 8);
    printf("%-12s %u\n", "TTL", bytes[8]);
    printf("%-12s %u (%s)\n", "protocolo", bytes[9], protocol_name(bytes[9]));
    printf("%-12s 0x%04x\n", "checksum", read_u16(&bytes[10]));
    print_address("origen", &bytes[12]);
    print_address("destino", &bytes[16]);
    return 0;
}
lpm.cDescargar lpm.c
// lpm.c — longest prefix match con una tabla de forwarding leída de un archivo.
//
//   ./lpm tabla.txt 10.1.2.3 10.2.0.1
//
// Cada línea de la tabla tiene "prefijo/largo siguiente-salto"; lo que sigue a
// un # es comentario. La búsqueda recorre toda la tabla: es lo más simple de
// leer, pero un router de verdad no puede darse ese lujo (ver el capítulo).
#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAX_ROUTES 256
#define HOP_SIZE 32

struct route {
    uint32_t network;   // en orden de host
    uint32_t mask;
    int length;
    char hop[HOP_SIZE];
};

static uint32_t mask_of(int length) {
    return length == 0 ? 0 : UINT32_MAX << (32 - length);
}

static const char *ip_text(uint32_t addr, char *out) {
    struct in_addr raw = {.s_addr = htonl(addr)};
    if (inet_ntop(AF_INET, &raw, out, INET_ADDRSTRLEN) == NULL) {
        perror("inet_ntop");
        exit(1);
    }
    return out;
}

// Lee una línea de la tabla. Devuelve 1 si había una ruta, 0 si la línea estaba
// vacía y -1 si está mal escrita.
static int parse_line(char *line, struct route *route) {
    char *comment = strchr(line, '#');
    if (comment != NULL) {
        *comment = '\0';
    }

    char prefix[32];
    char hop[HOP_SIZE];
    int fields = sscanf(line, "%31s %31s", prefix, hop);
    if (fields <= 0) {
        return 0;
    }
    if (fields != 2) {
        return -1;
    }

    char *slash = strchr(prefix, '/');
    if (slash == NULL) {
        return -1;
    }
    *slash = '\0';

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

    struct in_addr parsed;
    if (inet_pton(AF_INET, prefix, &parsed) != 1) {
        return -1;
    }

    route->length = (int)length;
    route->mask = mask_of(route->length);
    route->network = ntohl(parsed.s_addr);
    if ((route->network & route->mask) != route->network) {
        return -1;  // el prefijo tiene bits de host encendidos
    }
    strcpy(route->hop, hop);
    return 1;
}

static int load_table(const char *path, struct route *table) {
    FILE *file = fopen(path, "r");
    if (file == NULL) {
        perror(path);
        return -1;
    }

    char line[128];
    int count = 0;
    int line_number = 0;
    while (fgets(line, sizeof line, file) != NULL) {
        line_number++;
        struct route route;
        int result = parse_line(line, &route);
        if (result < 0) {
            fprintf(stderr, "%s:%d: línea inválida\n", path, line_number);
            fclose(file);
            return -1;
        }
        if (result == 1) {
            if (count == MAX_ROUTES) {
                fprintf(stderr, "%s: más de %d rutas\n", path, MAX_ROUTES);
                fclose(file);
                return -1;
            }
            table[count++] = route;
        }
    }
    if (ferror(file)) {
        perror(path);
        fclose(file);
        return -1;
    }
    if (fclose(file) != 0) {
        perror(path);
        return -1;
    }
    return count;
}

// Devuelve la ruta con el prefijo más largo que contiene a addr, o NULL. El
// orden de la tabla no importa: se revisan todas las entradas.
static const struct route *lookup(const struct route *table, int count, uint32_t addr) {
    const struct route *best = NULL;
    for (int i = 0; i < count; i++) {
        const struct route *route = &table[i];
        if ((addr & route->mask) == route->network
            && (best == NULL || route->length > best->length)) {
            best = route;
        }
    }
    return best;
}

int main(int argc, char *argv[]) {
    if (argc < 3) {
        fprintf(stderr, "uso: %s tabla dirección...\n", argv[0]);
        return 2;
    }

    static struct route table[MAX_ROUTES];
    int count = load_table(argv[1], table);
    if (count < 0) {
        return 2;
    }

    for (int a = 2; a < argc; a++) {
        struct in_addr parsed;
        if (inet_pton(AF_INET, argv[a], &parsed) != 1) {
            fprintf(stderr, "lpm: dirección inválida: %s\n", argv[a]);
            return 2;
        }
        uint32_t addr = ntohl(parsed.s_addr);

        printf("%s\n", argv[a]);
        char text[INET_ADDRSTRLEN];
        for (int i = 0; i < count; i++) {
            if ((addr & table[i].mask) == table[i].network) {
                printf("  coincide  %s/%d -> %s\n", ip_text(table[i].network, text),
                       table[i].length, table[i].hop);
            }
        }
        const struct route *best = lookup(table, count, addr);
        if (best == NULL) {
            printf("  sin ruta: se descarta\n");
        } else {
            printf("  gana      %s/%d -> %s\n", ip_text(best->network, text), best->length,
                   best->hop);
        }
    }
    return 0;
}
netlab.pyDescargar netlab.py
"""Ayudas de las pruebas de la semana 4: 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 load_nft(self, node, path):
        with open(path, encoding="utf-8") as file:
            done = run(["docker", "exec", "-i", self.prefix + node, "nft", "-f", "-"], stdin=file)
        if done.returncode != 0:
            raise RuntimeError(done.stderr)

    def python(self, node, code):
        """Corre un programa corto de Python en un nodo."""
        done = subprocess.run(["docker", "exec", "-i", self.prefix + node, "python3", "-"],
                              input=code, capture_output=True, text=True, timeout=60)
        return done.stdout + done.stderr

    def capture(self, node, interface, expression, count, seconds=5, flags=("-n", "-e", "-v")):
        """Empieza una captura; el resultado se pide con capture.result()."""
        return Capture(self.prefix + node, interface, expression, count, seconds, flags)


class Capture:
    def __init__(self, container, interface, expression, count, seconds, flags):
        self.process = subprocess.Popen(
            ["docker", "exec", container, "timeout", str(seconds), "tcpdump", "-l", *flags,
             "-i", interface, "-c", str(count), expression],
            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
        time.sleep(0.7)  # darle tiempo a tcpdump de abrir la interfaz

    def result(self):
        output, _ = self.process.communicate(timeout=30)
        return output


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()
table.txtDescargar table.txt
# Tabla de ejemplo del capítulo: prefijo y siguiente salto.
10.200.0.0/16    A
10.200.4.0/22    B
10.200.5.0/24    C
0.0.0.0/0        D
fwd/fwd.clab.ymlDescargar fwd/fwd.clab.yml
# Tres routers en triángulo, con un host en cada LAN. Las rutas estáticas están
# incompletas a propósito: completarlas es el paso 1 del laboratorio.
#
#   h1 --- r1 ------- r2 --- h2
#            \       /
#             \     /
#               r3 --- h3
name: w04-fwd
mgmt:
  network: redes-w04-fwd
  ipv4-subnet: 172.90.4.0/25
  ipv6-subnet: 3fff:172:90:4::/80
topology:
  defaults:
    kind: linux
    image: redes-router
  nodes:
    # Se borra la ruta por defecto de gestión (eth0): sin esto, lo que un nodo no
    # sabe a dónde mandar se iría por Docker en vez de fallar.
    r1:
      binds: [r1/frr.conf:/etc/frr/frr.conf:ro]
      exec: &router-exec
        - ip route del default
        - ip -6 route del default
        - sysctl -w net.ipv6.conf.all.forwarding=1
    r2:
      binds: [r2/frr.conf:/etc/frr/frr.conf:ro]
      exec: *router-exec
    r3:
      binds: [r3/frr.conf:/etc/frr/frr.conf:ro]
      exec: *router-exec
    h1:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 10.200.1.10/24 dev eth1
        - ip route add default via 10.200.1.1
    h2:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 10.200.2.10/24 dev eth1
        - ip route add default via 10.200.2.1
    h3:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 10.200.3.10/24 dev eth1
        - ip route add default via 10.200.3.1
  # Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
  links:
    - endpoints: ["h1:eth1", "r1:eth1"]
      mtu: 1500
    - endpoints: ["h2:eth1", "r2:eth1"]
      mtu: 1500
    - endpoints: ["h3:eth1", "r3:eth1"]
      mtu: 1500
    - endpoints: ["r1:eth2", "r2:eth2"]
      mtu: 1500
    - endpoints: ["r2:eth3", "r3:eth2"]
      mtu: 1500
    - endpoints: ["r1:eth3", "r3:eth3"]
      mtu: 1500
fwd/ipv6.shDescargar fwd/ipv6.sh
#!/bin/sh
# Direcciones IPv6 de ejemplo en el camino h1 - r1 - r3 (paso 6 del laboratorio).
# Se corre en el contenedor del curso, con fwd.clab.yml levantada.
set -e
P=clab-w04-fwd

docker exec $P-h1 ip addr replace 2001:db8:1::10/64 dev eth1
docker exec $P-h1 ip -6 route replace default via 2001:db8:1::1

docker exec $P-r1 ip addr replace 2001:db8:1::1/64 dev eth1
docker exec $P-r1 ip addr replace 2001:db8:13::1/64 dev eth3

docker exec $P-r3 ip addr replace 2001:db8:13::2/64 dev eth3
docker exec $P-r3 ip -6 route replace 2001:db8:1::/64 via 2001:db8:13::1

# Una dirección nueva pasa unos segundos en duplicate address detection; mientras
# tanto, el kernel no la usa como origen.
sleep 3
nat/dhcp_server.pyDescargar nat/dhcp_server.py
#!/usr/bin/env python3
"""Servidor DHCP mínimo para ver discover, offer, request y ack (RFC 2131).

Es material de clase, no un servidor de verdad: una sola subred, sin renovar
concesiones y sin revisar conflictos. Responde siempre por broadcast.

Uso: dhcp_server.py <interfaz> <dirección del servidor> <primer host> <último host>
Ejemplo: dhcp_server.py eth1 10.200.50.1 10.200.50.100 10.200.50.110
"""
import ipaddress
import socket
import struct
import sys

BOOTREQUEST, BOOTREPLY = 1, 2
MAGIC_COOKIE = b"\x63\x82\x53\x63"
DISCOVER, OFFER, REQUEST, ACK = 1, 2, 3, 5
LEASE_SECONDS = 600
NETMASK = "255.255.255.0"
MESSAGE_NAMES = {1: "DISCOVER", 2: "OFFER", 3: "REQUEST", 5: "ACK"}


def parse_options(data):
    """Devuelve las opciones DHCP como un diccionario código -> bytes."""
    options = {}
    i = 0
    while i < len(data) and data[i] != 255:
        if data[i] == 0:  # relleno
            i += 1
            continue
        code, length = data[i], data[i + 1]
        options[code] = data[i + 2 : i + 2 + length]
        i += 2 + length
    return options


def build_reply(request, message_type, offered, server):
    """Arma la respuesta copiando xid, flags y chaddr del pedido."""
    header = struct.pack("!BBBB", BOOTREPLY, 1, 6, 0) + request[4:12]
    header += b"\0" * 4 + socket.inet_aton(offered)  # ciaddr, yiaddr
    header += socket.inet_aton(server) + b"\0" * 4  # siaddr, giaddr
    header += request[28:44] + b"\0" * 192  # chaddr, sname y file
    options = bytes([53, 1, message_type])
    options += bytes([54, 4]) + socket.inet_aton(server)
    options += bytes([51, 4]) + struct.pack("!I", LEASE_SECONDS)
    options += bytes([1, 4]) + socket.inet_aton(NETMASK)
    options += bytes([3, 4]) + socket.inet_aton(server)  # router por defecto
    return header + MAGIC_COOKIE + options + b"\xff"


def main():
    if len(sys.argv) != 5:
        sys.exit(__doc__)
    device, server, first, last = sys.argv[1:]
    pool = [str(ipaddress.ip_address(n)) for n in range(
        int(ipaddress.ip_address(first)), int(ipaddress.ip_address(last)) + 1)]
    leases = {}  # MAC -> dirección

    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, device.encode())
    sock.bind(("0.0.0.0", 67))

    while True:
        data, _ = sock.recvfrom(1500)
        if len(data) < 240 or data[0] != BOOTREQUEST or data[236:240] != MAGIC_COOKIE:
            continue
        options = parse_options(data[240:])
        kind = options.get(53, b"\0")[0]
        mac = data[28:34]
        if kind == DISCOVER:
            free = [a for a in pool if a not in leases.values()]
            if mac not in leases and not free:
                print("sin direcciones libres", flush=True)
                continue
            leases.setdefault(mac, free[0] if free else None)
            answer = OFFER
        elif kind == REQUEST and mac in leases:
            answer = ACK
        else:
            continue
        print(f"{MESSAGE_NAMES[kind]} de {mac.hex(':')} -> "
              f"{MESSAGE_NAMES[answer]} {leases[mac]}", flush=True)
        reply = build_reply(data, answer, leases[mac], server)
        sock.sendto(reply, ("255.255.255.255", 68))


if __name__ == "__main__":
    main()
nat/dnat_demo.nftDescargar nat/dnat_demo.nft
# Redirección de puerto: lo que llega por eth2 al puerto 8080 de gw va a a1.
# Se agrega encima de masquerade.nft.
table ip nat {
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iifname "eth2" tcp dport 8080 dnat to 10.200.50.10:8080
    }
}
nat/masquerade.nftDescargar nat/masquerade.nft
# NAT de salida: lo que viene de la LAN y sale por eth2 toma la dirección de eth2.
flush ruleset
table ip nat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        ip saddr 10.200.50.0/24 oifname "eth2" masquerade
    }
}
nat/nat.clab.ymlDescargar nat/nat.clab.yml
# Una LAN privada detrás de un router de borde con NAT, un proveedor y un servidor
# externo. Las direcciones "públicas" salen de 100.64.0.0/10.
#
#   a1 ---+
#   a2 ---+-- sw --- gw ======= isp --- ext
#   a3 ---+
#     10.200.50.0/24   100.64.0.0/30   100.64.9.0/24
#
# a3 empieza sin dirección: la pide por DHCP.
name: w04-nat
mgmt:
  network: redes-w04-nat
  ipv4-subnet: 172.90.4.128/25
  ipv6-subnet: 3fff:172:90:4:1::/80
topology:
  defaults:
    kind: linux
    image: redes-router
  nodes:
    # Se borra la ruta por defecto de gestión (eth0): sin esto, lo que un nodo no
    # sabe a dónde mandar se iría por Docker en vez de fallar.
    a1:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 10.200.50.10/24 dev eth1
        - ip route add default via 10.200.50.1
    a2:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 10.200.50.11/24 dev eth1
        - ip route add default via 10.200.50.1
    a3:
      exec:
        - ip route del default
        - ip -6 route del default
    sw:
      exec:
        - ip route del default
        - ip -6 route del default
        - 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 eth4 master br0
        - ip link set br0 up
    gw:
      binds:
        - gw/frr.conf:/etc/frr/frr.conf:ro
        - dhcp_server.py:/opt/dhcp_server.py
      exec: ["ip route del default", "ip -6 route del default"]
    isp:
      binds: [isp/frr.conf:/etc/frr/frr.conf:ro]
      exec: ["ip route del default", "ip -6 route del default"]
    ext:
      exec:
        - ip route del default
        - ip -6 route del default
        - ip addr add 100.64.9.10/24 dev eth1
        - ip route add default via 100.64.9.1
  # Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
  links:
    - endpoints: ["a1:eth1", "sw:eth1"]
      mtu: 1500
    - endpoints: ["a2:eth1", "sw:eth2"]
      mtu: 1500
    - endpoints: ["a3:eth1", "sw:eth3"]
      mtu: 1500
    - endpoints: ["gw:eth1", "sw:eth4"]
      mtu: 1500
    - endpoints: ["gw:eth2", "isp:eth1"]
      mtu: 1500
    - endpoints: ["isp:eth2", "ext:eth1"]
      mtu: 1500
fwd/r1/frr.confDescargar fwd/r1/frr.conf
frr defaults traditional
hostname r1
interface eth1
 ip address 10.200.1.1/24
interface eth2
 ip address 10.200.12.1/30
interface eth3
 ip address 10.200.13.1/30
! Ruta inicial: la LAN de h3 se alcanza por el enlace directo a r3.
ip route 10.200.3.0/24 10.200.13.2
fwd/r2/frr.confDescargar fwd/r2/frr.conf
frr defaults traditional
hostname r2
interface eth1
 ip address 10.200.2.1/24
interface eth2
 ip address 10.200.12.2/30
interface eth3
 ip address 10.200.23.1/30
fwd/r3/frr.confDescargar fwd/r3/frr.conf
frr defaults traditional
hostname r3
interface eth1
 ip address 10.200.3.1/24
interface eth2
 ip address 10.200.23.2/30
interface eth3
 ip address 10.200.13.2/30
! Ruta inicial: la LAN de h1 se alcanza por el enlace directo a r1.
ip route 10.200.1.0/24 10.200.13.1
nat/gw/frr.confDescargar nat/gw/frr.conf
frr defaults traditional
hostname gw
interface eth1
 ip address 10.200.50.1/24
interface eth2
 ip address 100.64.0.2/30
! Todo lo que no es de la LAN sale hacia el proveedor.
ip route 0.0.0.0/0 100.64.0.1
nat/isp/frr.confDescargar nat/isp/frr.conf
frr defaults traditional
hostname isp
interface eth1
 ip address 100.64.0.1/30
interface eth2
 ip address 100.64.9.1/24
! Sin ruta hacia 10.200.50.0/24: el proveedor no conoce las direcciones privadas.

Pruebas

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

test_fwd.pyDescargar test_fwd.py
"""Pruebas de forwarding, ICMP, fragmentación, colas e IPv6 en fwd/fwd.clab.yml."""
import os
import re
import subprocess
import unittest

from netlab import HERE, Lab, answer_path, wait_until

LAB = Lab("fwd", "w04-fwd", "redes-w04-fwd")

NO_DF_SENDER = """
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.IPPROTO_IP, 10, 0)  # IP_MTU_DISCOVER = IP_PMTUDISC_DONT: sin DF
s.sendto(b"x" * 1400, ("10.200.3.10", 9999))
"""


def setUpModule():
    unittest.addModuleCleanup(LAB.destroy)
    LAB.deploy()
    ok = wait_until(lambda: "10.200.3.0/24" in LAB.out("r1", "ip", "route")
                    and "10.200.1.0/24" in LAB.out("r3", "ip", "route"))
    if not ok:
        raise RuntimeError("las rutas iniciales no aparecieron")


def average_rtt(output):
    match = re.search(r"min/avg/max = [\d.]+/([\d.]+)/", output)
    return float(match.group(1)) if match else None


def set_link_mtu(mtu):
    LAB.sh("r1", "ip", "link", "set", "eth3", "mtu", str(mtu))
    LAB.sh("r3", "ip", "link", "set", "eth3", "mtu", str(mtu))


class A_StartingState(unittest.TestCase):
    def test_h1_reaches_h3(self):
        self.assertEqual(LAB.sh("h1", "ping", "-c", "2", "-W", "2", "10.200.3.10").returncode, 0)

    def test_no_default_route_through_management(self):
        self.assertNotIn("default", LAB.out("r1", "ip", "route"))
        self.assertNotIn("default", LAB.out("r2", "ip", "route"))

    def test_links_use_ethernet_mtu(self):
        self.assertIn("mtu 1500", LAB.out("h1", "ip", "link", "show", "eth1"))

    def test_missing_route_gives_net_unreachable(self):
        capture = LAB.capture("h1", "eth1", "icmp", 2)
        LAB.sh("h1", "ping", "-c", "1", "-W", "1", "10.200.2.10")
        self.assertIn("net 10.200.2.10 unreachable", capture.result())
        self.assertIn("unreachable", LAB.out("r1", "ip", "route", "get", "10.200.2.10"))


class B_PerHop(unittest.TestCase):
    """Lo que cambia y lo que no en cada salto, en el camino h1 - r1 - r3 - h3."""

    def captures(self):
        points = [("h1", "eth1"), ("r1", "eth3"), ("r3", "eth1")]
        running = [LAB.capture(n, i, "icmp[icmptype] == icmp-echo", 1) for n, i in points]
        LAB.sh("h1", "ping", "-c", "1", "-W", "2", "10.200.3.10")
        return [c.result() for c in running]

    def test_ttl_macs_and_ips(self):
        outputs = self.captures()
        self.assertEqual([int(re.search(r"ttl (\d+)", o).group(1)) for o in outputs], [64, 63, 62])
        for output in outputs:
            self.assertIn("10.200.1.10 > 10.200.3.10", output)
        macs = [re.match(r"\S+ (\S+) > (\S+),", o).groups() for o in outputs]
        self.assertEqual(len(set(macs)), 3)

    def test_checksum_is_redone_at_each_hop(self):
        points = [("r1", "eth1"), ("r1", "eth3")]
        running = [LAB.capture(n, i, "icmp[icmptype] == icmp-echo", 1, flags=("-n", "-x"))
                   for n, i in points]
        LAB.sh("h1", "ping", "-c", "1", "-W", "2", "10.200.3.10")
        words = [re.search(r"0x0000:\s+(.*)", c.result()).group(1).split() for c in running]
        # Palabras 4 (TTL y protocolo) y 5 (checksum) del encabezado.
        self.assertEqual(int(words[0][4], 16) - int(words[1][4], 16), 0x100)
        self.assertEqual(int(words[1][5], 16) - int(words[0][5], 16), 0x100)
        self.assertEqual(words[0][:4], words[1][:4])


class C_Icmp(unittest.TestCase):
    def test_ttl_exceeded(self):
        capture = LAB.capture("h1", "eth1", "icmp", 2)
        LAB.sh("h1", "ping", "-c", "1", "-W", "1", "-t", "1", "10.200.3.10")
        self.assertIn("10.200.1.1 > 10.200.1.10: ICMP time exceeded", capture.result())

    def test_host_unreachable_comes_from_last_router(self):
        capture = LAB.capture("h1", "eth1", "icmp", 2, seconds=9)
        LAB.sh("h1", "ping", "-c", "1", "-W", "6", "10.200.3.99")
        self.assertIn("10.200.13.2 > 10.200.1.10: ICMP host 10.200.3.99 unreachable",
                      capture.result())

    def test_port_unreachable_comes_from_destination(self):
        capture = LAB.capture("h1", "eth1", "icmp", 1)
        LAB.sh("h1", "sh", "-c", "echo hola | nc -u -w1 10.200.3.10 9999")
        self.assertIn("10.200.3.10 udp port 9999 unreachable", capture.result())

    def test_traceroute_ends_at_destination(self):
        lines = LAB.out("h1", "traceroute", "-n", "-w", "1", "10.200.3.10").splitlines()
        self.assertIn("10.200.1.1", lines[1])
        self.assertIn("10.200.13.2", lines[2])
        self.assertIn("10.200.3.10", lines[3])


class D_Fragmentation(unittest.TestCase):
    def setUp(self):
        set_link_mtu(600)
        LAB.sh("h1", "ip", "route", "flush", "cache")
        self.addCleanup(LAB.sh, "h1", "ip", "route", "flush", "cache")
        self.addCleanup(set_link_mtu, 1500)

    def test_df_gets_need_to_frag_and_host_then_fragments(self):
        capture = LAB.capture("h1", "eth1", "icmp", 2)
        first = LAB.sh("h1", "ping", "-c", "1", "-W", "1", "-s", "1400", "10.200.3.10")
        self.assertNotEqual(first.returncode, 0)
        output = capture.result()
        self.assertIn("flags [DF]", output)
        self.assertIn("need to frag (mtu 600)", output)
        self.assertIn("mtu 600", LAB.out("h1", "ip", "route", "get", "10.200.3.10"))

        # h1 ya conoce el MTU del camino: fragmenta él, y el ping funciona.
        at_source = LAB.capture("h1", "eth1", "ip[6:2] & 0x3fff != 0 and src 10.200.1.10", 3)
        second = LAB.sh("h1", "ping", "-c", "1", "-W", "2", "-s", "1400", "10.200.3.10")
        self.assertEqual(second.returncode, 0)
        self.assertEqual(len(re.findall(r"offset \d+", at_source.result())), 3)

    def test_without_df_the_router_fragments(self):
        before = LAB.capture("h1", "eth1", "udp", 1)
        after = LAB.capture("r1", "eth3", "src 10.200.1.10", 3)
        self.assertEqual(LAB.python("h1", NO_DF_SENDER), "")
        self.assertIn("offset 0, flags [none]", before.result())
        offsets = re.findall(r"offset (\d+), flags \[(\+|none)\]", after.result())
        self.assertEqual(offsets, [("0", "+"), ("576", "+"), ("1152", "none")])


class E_Queues(unittest.TestCase):
    def setUp(self):
        LAB.sh("r1", "tc", "qdisc", "add", "dev", "eth3", "root", "handle", "1:", "tbf",
               "rate", "10mbit", "burst", "16kb", "limit", "256kb")
        self.addCleanup(LAB.sh, "r1", "tc", "qdisc", "del", "dev", "eth3", "root")
        LAB.daemon("h3", "iperf3 -s -1")
        wait_until(lambda: "5201" in LAB.out("h3", "netstat", "-tln"), seconds=5)

    def load(self):
        return subprocess.Popen(
            ["docker", "exec", LAB.prefix + "h1", "iperf3", "-c", "10.200.3.10", "-t", "7"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    def test_queue_delay_and_strict_priority(self):
        idle = average_rtt(LAB.out("h1", "ping", "-c", "5", "-i", "0.2", "10.200.3.10"))
        traffic = self.load()
        self.addCleanup(traffic.wait)
        wait_until(lambda: "backlog 0b" not in LAB.out("r1", "tc", "-s", "qdisc", "show",
                                                       "dev", "eth3"), seconds=4, step=0.2)
        loaded = average_rtt(LAB.out("h1", "ping", "-c", "8", "-i", "0.25", "10.200.3.10"))
        # Las colas dependen de la máquina: solo se exige que el retardo se note.
        self.assertGreater(loaded, 10)
        self.assertGreater(loaded, 5 * idle)
        traffic.wait()

        # Con una banda de prioridad para ICMP, el ping se adelanta a la cola.
        LAB.sh("r1", "tc", "qdisc", "add", "dev", "eth3", "parent", "1:1", "handle", "10:", "prio")
        for rule in ("add table ip qos",
                     "add chain ip qos mark_icmp { type filter hook forward priority 0; }",
                     "add rule ip qos mark_icmp ip protocol icmp meta priority set 10:1"):
            self.assertEqual(LAB.sh("r1", "nft", rule).returncode, 0, rule)
        self.addCleanup(LAB.sh, "r1", "nft", "delete", "table", "ip", "qos")
        LAB.daemon("h3", "iperf3 -s -1")
        wait_until(lambda: "5201" in LAB.out("h3", "netstat", "-tln"), seconds=5)
        traffic = self.load()
        self.addCleanup(traffic.wait)
        wait_until(lambda: "backlog 0b" not in LAB.out("r1", "tc", "-s", "qdisc", "show",
                                                       "dev", "eth3"), seconds=4, step=0.2)
        priority = average_rtt(LAB.out("h1", "ping", "-c", "8", "-i", "0.25", "10.200.3.10"))
        self.assertLess(priority, loaded / 2)
        traffic.wait()


class F_Ipv6(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        done = subprocess.run(["sh", os.path.join(HERE, "fwd", "ipv6.sh")],
                              capture_output=True, text=True)
        if done.returncode != 0:
            raise RuntimeError(done.stderr)

    def test_hop_limit_drops_at_router(self):
        LAB.sh("h1", "ip", "-6", "route", "flush", "cache")
        before = LAB.capture("r1", "eth1", "icmp6 and ip6[40] == 128", 1, flags=("-n", "-v"))
        after = LAB.capture("r1", "eth3", "icmp6 and ip6[40] == 128", 1, flags=("-n", "-v"))
        self.assertEqual(LAB.sh("h1", "ping", "-c", "1", "-W", "2",
                                "2001:db8:13::2").returncode, 0)
        self.assertIn("hlim 64", before.result())
        self.assertIn("hlim 63", after.result())

    def test_ping_and_neighbor_discovery(self):
        LAB.sh("h1", "ip", "-6", "neigh", "flush", "dev", "eth1")
        capture = LAB.capture("h1", "eth1", "icmp6", 4, flags=("-n", "-v"))
        wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2",
                                  "2001:db8:13::2").returncode == 0, seconds=10)
        output = capture.result()
        self.assertIn("neighbor solicitation", output)
        self.assertRegex(output, r"> ff02::1:ff00:1\b")
        self.assertIn("echo request", output)

    def test_router_sends_packet_too_big_and_source_fragments(self):
        set_link_mtu(1280)
        self.addCleanup(set_link_mtu, 1500)
        LAB.sh("h1", "ip", "-6", "route", "flush", "cache")
        capture = LAB.capture("h1", "eth1", "icmp6 and ip6[40] != 135 and ip6[40] != 136", 2,
                              flags=("-n", "-v"))
        LAB.sh("h1", "ping", "-c", "1", "-W", "1", "-s", "1400", "2001:db8:13::2")
        self.assertIn("packet too big, mtu 1280", capture.result())
        # Ahora fragmenta el origen, con un encabezado de extensión.
        fragments = LAB.capture("h1", "eth1", "ip6[6] == 44", 2, flags=("-n", "-v"))
        done = LAB.sh("h1", "ping", "-c", "1", "-W", "2", "-s", "1400", "2001:db8:13::2")
        self.assertEqual(done.returncode, 0)
        self.assertIn("frag (", fragments.result())


class G_LabRoutes(unittest.TestCase):
    """Pasos 1, 2 y 6 del laboratorio. Se saltan hasta que completen lab/."""

    @classmethod
    def setUpClass(cls):
        done = subprocess.run(["sh", answer_path("routes.sh")], capture_output=True, text=True)
        if done.returncode != 0:
            raise RuntimeError(done.stderr)
        wait_until(lambda: "10.200.2.0/24" in LAB.out("r1", "ip", "route"))

    def test_1_all_hosts_reach_each_other(self):
        addresses = {"h1": "10.200.1.10", "h2": "10.200.2.10", "h3": "10.200.3.10"}
        for source in addresses:
            for target, address in addresses.items():
                if source != target:
                    done = LAB.sh(source, "ping", "-c", "1", "-W", "2", address)
                    self.assertEqual(done.returncode, 0, f"{source} -> {target}")

    def test_2_more_specific_route_wins_and_makes_path_asymmetric(self):
        LAB.sh("r1", "vtysh", "-c", "conf t", "-c", "ip route 10.200.3.10/32 10.200.12.2")
        self.addCleanup(LAB.sh, "r1", "vtysh", "-c", "conf t",
                        "-c", "no ip route 10.200.3.10/32 10.200.12.2")
        self.assertTrue(wait_until(lambda: "10.200.3.10 nhid" in LAB.out("r1", "ip", "route")
                                   or "10.200.3.10 via" in LAB.out("r1", "ip", "route")))
        self.assertIn("via 10.200.12.2", LAB.out("r1", "ip", "route", "get", "10.200.3.10"))
        self.assertIn("via 10.200.13.2", LAB.out("r1", "ip", "route", "get", "10.200.3.11"))
        hops = LAB.out("h1", "traceroute", "-n", "-w", "1", "10.200.3.10").splitlines()[1:]
        self.assertEqual(len(hops), 4)
        self.assertIn("10.200.12.2", hops[1])
        # r3 responde con la dirección de la interfaz por la que sale hacia h1.
        self.assertIn("10.200.13.2", hops[2])
        # A la vuelta, r3 no pasa por r2.
        at_r2 = LAB.capture("r2", "eth3", "icmp", 1, seconds=3)
        at_r3 = LAB.capture("r3", "eth3", "icmp and src 10.200.3.10", 1, seconds=3)
        LAB.sh("h1", "ping", "-c", "1", "-W", "2", "10.200.3.10")
        self.assertIn("echo request", at_r2.result())
        self.assertIn("echo reply", at_r3.result())

    def test_3_router_that_stays_silent_shows_asterisks(self):
        LAB.sh("r1", "vtysh", "-c", "conf t", "-c", "ip route 10.200.3.10/32 10.200.12.2")
        self.addCleanup(LAB.sh, "r1", "vtysh", "-c", "conf t",
                        "-c", "no ip route 10.200.3.10/32 10.200.12.2")
        wait_until(lambda: "10.200.3.10 " in LAB.out("r1", "ip", "route"))
        for rule in ("add table ip quiet",
                     "add chain ip quiet out { type filter hook output priority 0; }",
                     "add rule ip quiet out icmp type time-exceeded drop"):
            self.assertEqual(LAB.sh("r3", "nft", rule).returncode, 0, rule)
        self.addCleanup(LAB.sh, "r3", "nft", "delete", "table", "ip", "quiet")
        hops = LAB.out("h1", "traceroute", "-n", "-w", "1", "10.200.3.10").splitlines()[1:]
        self.assertIn("* * *", hops[2].replace("  ", " "))
        self.assertIn("10.200.3.10", hops[3])

    def test_4_ipv6_reaches_h3(self):
        path = answer_path("ipv6_h3.sh")
        done = subprocess.run(["sh", path], capture_output=True, text=True)
        self.assertEqual(done.returncode, 0, done.stderr)
        self.assertTrue(wait_until(lambda: LAB.sh("h1", "ping", "-c", "1", "-W", "2",
                                                  "2001:db8:3::10").returncode == 0, seconds=10))
        match = re.search(r"ttl=(\d+)", LAB.out("h1", "ping", "-c", "1", "2001:db8:3::10"))
        self.assertEqual(match.group(1), "62")


if __name__ == "__main__":
    unittest.main()
test_nat.pyDescargar test_nat.py
"""Pruebas de DHCP y NAT en nat/nat.clab.yml."""
import os
import re
import time
import unittest

from netlab import HERE, Lab, answer_path, wait_until

LAB = Lab("nat", "w04-nat", "redes-w04-nat")
GW_PUBLIC = "100.64.0.2"
EXT = "100.64.9.10"


def setUpModule():
    unittest.addModuleCleanup(LAB.destroy)
    LAB.deploy()
    ok = wait_until(lambda: "default" in LAB.out("gw", "ip", "route")
                    and "100.64.9.0/24" in LAB.out("isp", "ip", "route")
                    and LAB.sh("isp", "ping", "-c", "1", "-W", "1", EXT).returncode == 0)
    if not ok:
        raise RuntimeError("la topología no quedó lista")
    LAB.daemon("ext", "nc -lk -p 9000 -e /bin/cat")


def reset_nat():
    LAB.sh("gw", "nft", "flush", "ruleset")
    LAB.sh("gw", "conntrack", "-F")


class A_Dhcp(unittest.TestCase):
    def test_lease_with_four_messages_by_broadcast(self):
        self.assertEqual(LAB.out("a3", "ip", "-4", "addr", "show", "dev", "eth1").count("inet "), 0)
        LAB.daemon("gw", "python3 /opt/dhcp_server.py eth1 10.200.50.1 "
                         "10.200.50.100 10.200.50.110 > /tmp/dhcp.log 2>&1")
        time.sleep(1)
        at_client = LAB.capture("sw", "eth3", "udp port 67 or udp port 68", 4, seconds=8,
                                flags=("-n", "-e", "-v"))
        at_other = LAB.capture("a2", "eth1", "udp port 67 or udp port 68", 4, seconds=8,
                               flags=("-n", "-e", "-v"))
        done = LAB.sh("a3", "udhcpc", "-i", "eth1", "-n", "-q", "-f", timeout=30)
        self.assertEqual(done.returncode, 0, done.stderr)
        output = at_client.result()
        for name in ("Discover", "Offer", "Request", "ACK"):
            self.assertIn(f"DHCP-Message (53), length 1: {name}", output)
        self.assertEqual(output.count("> ff:ff:ff:ff:ff:ff"), 4)
        self.assertIn("0.0.0.0.68 > 255.255.255.255.67", output)
        # Otro host de la LAN también ve los mensajes: es broadcast.
        self.assertIn("Discover", at_other.result())

        self.assertIn("10.200.50.100/24", LAB.out("a3", "ip", "-4", "addr", "show", "dev", "eth1"))
        self.assertIn("default via 10.200.50.1", LAB.out("a3", "ip", "route"))
        self.assertEqual(LAB.sh("a3", "ping", "-c", "1", "-W", "2", "10.200.50.1").returncode, 0)


class B_Nat(unittest.TestCase):
    def setUp(self):
        reset_nat()
        self.addCleanup(reset_nat)

    def load_masquerade(self):
        LAB.load_nft("gw", os.path.join(HERE, "nat", "masquerade.nft"))

    def test_without_nat_private_source_does_not_come_back(self):
        capture = LAB.capture("gw", "eth2", "icmp", 2, seconds=3, flags=("-n",))
        done = LAB.sh("a1", "ping", "-c", "1", "-W", "1", EXT)
        self.assertNotEqual(done.returncode, 0)
        self.assertIn(f"10.200.50.10 > {EXT}", capture.result())

    def test_masquerade_rewrites_source_on_the_way_out_and_back(self):
        self.load_masquerade()
        inside = LAB.capture("gw", "eth1", "icmp", 2, flags=("-n",))
        outside = LAB.capture("gw", "eth2", "icmp", 2, flags=("-n",))
        at_ext = LAB.capture("ext", "eth1", "icmp", 2, flags=("-n",))
        self.assertEqual(LAB.sh("a1", "ping", "-c", "1", "-W", "2", EXT).returncode, 0)
        self.assertIn(f"10.200.50.10 > {EXT}", inside.result())
        self.assertIn(f"{GW_PUBLIC} > {EXT}", outside.result())
        at_ext = at_ext.result()
        self.assertIn(f"{GW_PUBLIC} > {EXT}", at_ext)
        self.assertNotIn("10.200.50", at_ext)

    def test_same_source_port_from_two_hosts_gets_two_external_ports(self):
        self.load_masquerade()
        syns = LAB.capture("ext", "eth1", "tcp[tcpflags] & tcp-syn != 0 and dst port 9000", 2,
                           seconds=6, flags=("-n",))
        LAB.daemon("a1", f"sleep 8 | nc -p 40000 {EXT} 9000")
        LAB.daemon("a2", f"sleep 8 | nc -p 40000 {EXT} 9000")
        ports = re.findall(rf"{GW_PUBLIC}\.(\d+) > ", syns.result())
        self.assertEqual(len(set(ports)), 2)
        table = LAB.out("gw", "conntrack", "-L", "-p", "tcp")
        self.assertRegex(table, r"src=10\.200\.50\.10 .*sport=40000")
        self.assertRegex(table, r"src=10\.200\.50\.11 .*sport=40000")

    def test_private_address_is_unreachable_from_outside(self):
        self.load_masquerade()
        capture = LAB.capture("ext", "eth1", "icmp", 1, flags=("-n",))
        done = LAB.sh("ext", "curl", "-sS", "--connect-timeout", "2",
                      "http://10.200.50.10:8080/")
        self.assertNotEqual(done.returncode, 0)
        self.assertIn("10.200.50.10 unreachable", capture.result())

    def test_public_address_refuses_until_port_is_redirected(self):
        self.load_masquerade()
        LAB.daemon("a1", "nc -lk -p 8080 -e /bin/echo hola-desde-a1")
        time.sleep(0.5)
        capture = LAB.capture("gw", "eth2", "tcp port 8080", 2, flags=("-n",))
        before = LAB.sh("ext", "curl", "-sS", "--connect-timeout", "2",
                        f"http://{GW_PUBLIC}:8080/")
        self.assertNotEqual(before.returncode, 0)
        self.assertIn("Flags [R.]", capture.result())

        LAB.load_nft("gw", os.path.join(HERE, "nat", "dnat_demo.nft"))
        after = LAB.sh("ext", "sh", "-c", f"echo x | nc -w2 {GW_PUBLIC} 8080")
        self.assertIn("hola-desde-a1", after.stdout)
        table = LAB.out("gw", "conntrack", "-L", "-p", "tcp", "--dport", "8080")
        self.assertRegex(table, rf"dst={GW_PUBLIC} sport=\d+ dport=8080 src=10\.200\.50\.10")

    def test_lab_step_5_port_forward(self):
        path = answer_path("portforward.nft")
        self.load_masquerade()
        LAB.load_nft("gw", path)
        LAB.daemon("a2", "nc -lk -p 8000 -e /bin/echo hola-desde-a2")
        time.sleep(0.5)
        done = LAB.sh("ext", "sh", "-c", f"echo x | nc -w2 {GW_PUBLIC} 2222")
        self.assertIn("hola-desde-a2", done.stdout)
        # a1 no recibe nada por ese puerto, y sigue saliendo con NAT.
        self.assertEqual(LAB.sh("a1", "ping", "-c", "1", "-W", "2", EXT).returncode, 0)


if __name__ == "__main__":
    unittest.main()
test_tools.pyDescargar test_tools.py
"""Pruebas de los programas en C: cidr, lpm e ipv4_header."""
import os
import subprocess
import tempfile
import unittest

from netlab import HERE

# Encabezado capturado en r1 (eth1): TTL 64, antes del forwarding.
CAPTURED = "\t0x0000:  4500 0054 73b5 4000 4001 ad50 0ac8 010a\n" \
           "\t0x0010:  0ac8 030a 0800 5973 0099 0000 e30f bae3\n"


def tool(name, *args, stdin=None):
    return subprocess.run([os.path.join(HERE, name), *args], capture_output=True, text=True,
                          input=stdin, timeout=10)


def internet_checksum(header):
    """Suma de complemento a uno de palabras de 16 bits (solo para verificar la captura)."""
    total = sum(int.from_bytes(header[i:i + 2], "big") for i in range(0, len(header), 2))
    while total >> 16:
        total = (total & 0xffff) + (total >> 16)
    return ~total & 0xffff


class CidrTest(unittest.TestCase):
    def test_info_of_22(self):
        out = tool("cidr", "10.20.0.0/22").stdout
        self.assertIn("255.255.252.0", out)
        self.assertIn("10.20.3.255", out)
        self.assertIn("(1022)", out)

    def test_split_guide_example(self):
        out = tool("cidr", "10.20.0.0/22", "4").stdout
        for sub in ("10.20.0.0/24", "10.20.1.0/24", "10.20.2.0/24", "10.20.3.0/24"):
            self.assertIn(sub, out)

    def test_host_bits_are_reported(self):
        out = tool("cidr", "10.1.2.3/24").stdout
        self.assertIn("bits de host", out)
        self.assertIn("10.1.2.0/24", out)

    def test_edges(self):
        self.assertIn("255.255.255.254", tool("cidr", "10.0.0.0/31").stdout)
        self.assertIn("(2)", tool("cidr", "10.0.0.0/31").stdout)
        self.assertIn("(1)", tool("cidr", "10.0.0.1/32").stdout)
        self.assertIn("0.0.0.0", tool("cidr", "0.0.0.0/0").stdout)

    def test_invalid_input(self):
        self.assertEqual(tool("cidr", "10.0.0.0/33").returncode, 2)
        self.assertEqual(tool("cidr", "300.0.0.0/8").returncode, 2)
        self.assertEqual(tool("cidr", "10.0.0.0").returncode, 2)
        self.assertEqual(tool("cidr", "10.0.0.0/24", "3").returncode, 1)
        self.assertEqual(tool("cidr", "10.0.0.0/31", "4").returncode, 1)


class LpmTest(unittest.TestCase):
    TABLE = os.path.join(HERE, "table.txt")

    def winner(self, address, table=None):
        out = tool("lpm", table or self.TABLE, address).stdout
        lines = [line for line in out.splitlines() if "gana" in line or "sin ruta" in line]
        return lines[0].split()[-1]

    def test_chapter_example(self):
        self.assertEqual(self.winner("10.200.5.9"), "C")
        self.assertEqual(self.winner("10.200.6.1"), "B")
        self.assertEqual(self.winner("10.200.9.1"), "A")

    def test_default_route(self):
        self.assertEqual(self.winner("100.64.9.10"), "D")

    def test_order_does_not_matter(self):
        with open(self.TABLE, encoding="utf-8") as file:
            lines = file.read().splitlines()
        with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as other:
            other.write("\n".join(reversed(lines)) + "\n")
        try:
            self.assertEqual(self.winner("10.200.5.9", other.name), "C")
        finally:
            os.unlink(other.name)

    def test_no_route_without_default(self):
        with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as other:
            other.write("10.0.0.0/8 A\n")
        try:
            self.assertIn("sin ruta", tool("lpm", other.name, "11.0.0.1").stdout)
        finally:
            os.unlink(other.name)

    def test_rejects_prefix_with_host_bits(self):
        with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as other:
            other.write("10.1.2.3/24 A\n")
        try:
            self.assertEqual(tool("lpm", other.name, "10.1.2.3").returncode, 2)
        finally:
            os.unlink(other.name)


class HeaderTest(unittest.TestCase):
    def test_fields_of_captured_packet(self):
        out = tool("ipv4_header", stdin=CAPTURED).stdout
        self.assertRegex(out, r"TTL\s+64")
        self.assertRegex(out, r"DF=1 MF=0")
        self.assertRegex(out, r"largo total\s+84 bytes")
        self.assertRegex(out, r"protocolo\s+1 \(ICMP\)")
        self.assertRegex(out, r"origen\s+10\.200\.1\.10")
        self.assertRegex(out, r"destino\s+10\.200\.3\.10")

    def test_rejects_short_and_non_ipv4(self):
        self.assertEqual(tool("ipv4_header", stdin="\t0x0000:  4500 0054\n").returncode, 1)
        ipv6 = "\t0x0000:  6000 0000 0040 3a40 2001 0db8 0001 0000\n" \
               "\t0x0010:  0000 0000 0000 0010 2001 0db8 0001 0000\n"
        self.assertEqual(tool("ipv4_header", stdin=ipv6).returncode, 1)

    def test_checksum_changes_by_one_hundred_hex_when_ttl_drops(self):
        before = bytes.fromhex("4500005473b540004001" "ad50" "0ac8010a0ac8030a")
        after = bytes.fromhex("4500005473b540003f01" "ae50" "0ac8010a0ac8030a")
        # Un encabezado correcto, con su checksum incluido, suma 0xffff: el
        # complemento da 0.
        self.assertEqual(internet_checksum(before), 0)
        self.assertEqual(internet_checksum(after), 0)
        self.assertEqual(0xae50 - 0xad50, 0x100)


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

Laboratorio

Archivos de partida del laboratorio de la semana.

lab/ipv6_h3.shDescargar lab/ipv6_h3.sh
#!/bin/sh
# Paso 6 del laboratorio: fwd/ipv6.sh deja IPv6 hasta r3. Completen lo que falta
# para que h1 haga ping a h3 en 2001:db8:3::10. Se corre después de fwd/ipv6.sh.
# Cuando terminen, borren la línea TODO: make test usa este archivo.
set -e
P=clab-w04-fwd

# TODO: direcciones y rutas que faltan
lab/portforward.nftDescargar lab/portforward.nft
# Paso 5 del laboratorio: quien se conecte desde afuera al puerto 2222 de gw
# (100.64.0.2) debe llegar al puerto 8000 de a2. Se carga encima de
# nat/masquerade.nft con: docker exec -i clab-w04-nat-gw nft -f - < lab/portforward.nft
# Cuando terminen, borren la línea TODO: make test usa este archivo.

# TODO: una tabla con la cadena y la regla
lab/routes.shDescargar lab/routes.sh
#!/bin/sh
# Paso 1 del laboratorio: agreguen las rutas que faltan para que h1, h2 y h3 se
# alcancen entre sí. Se corre en el contenedor del curso, con fwd.clab.yml levantada.
# Cuando terminen, borren la línea TODO: make test usa este archivo.
set -e
P=clab-w04-fwd

# Ejemplo de la forma que tiene cada ruta:
# docker exec $P-r1 vtysh -c 'conf t' -c 'ip route PREFIJO SIGUIENTE-SALTO'

# TODO: rutas de r1, r2 y r3

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 4: IP, forwarding y NAT. Se usa dentro del contenedor del curso.
#
#   make          compila cidr, lpm e ipv4_header
#   make test     compila, levanta las dos topologías una tras otra y corre las
#                 pruebas (test_*.py); siempre destruye lo que levantó. Lo que
#                 depende del laboratorio usa lab/ y se salta hasta que lo completen
#   make up-fwd   levanta la topología de tres routers (fwd/fwd.clab.yml)
#   make up-nat   levanta la red con NAT (nat/nat.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 -O2 -g
PROGRAMS = cidr lpm ipv4_header

.PHONY: all test up-fwd up-nat down clean

all: $(PROGRAMS)

%: %.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-fwd:
	cd fwd && containerlab deploy -t fwd.clab.yml --reconfigure

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

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

clean: down
	rm -f $(PROGRAMS)
	rm -rf __pycache__ fwd/clab-w04-fwd nat/clab-w04-nat