Semana 12 — DNS y HTTP

Objetivos

  • Seguir una resolución de DNS desde la raíz hasta la respuesta.
  • Leer registros de DNS e interpretar el TTL.
  • Reconocer ataques a DNS y qué defensas existen.
  • Escribir y leer mensajes de HTTP/1.1, y explicar conexiones persistentes y caché.
  • Explicar qué problema resuelve HTTP/2.

Sesión A — DNS: jerarquía, resolución y seguridad

Lecturas:

  • Kurose y Ross, sec. 2.4 (DNS: The Internet’s Directory Service).
  • CS168, DNS.

Lecturas complementarias:

Temas:

  • Por qué un nombre y una dirección son cosas distintas. Qué servicios da DNS, además de traducir nombres.
  • Jerarquía: raíz, TLD y servidores autoritativos. Resolver recursivo.
  • Consultas recursivas e iterativas. Caché y TTL.
  • Registros: A, AAAA, NS, CNAME, MX y TXT.
  • Por qué DNS usa UDP, y cuándo usa TCP.
  • Ataques: envenenamiento de caché, y cómo ayudan el ID aleatorio y el puerto aleatorio. DNSSEC autentica; DoH y DoT cifran.
  • Error común: creer que DNSSEC da privacidad. Solo autentica las respuestas; quien mira la red todavía ve la consulta.

Sesión B — HTTP/1.1, HTTP/2 y caché

Lecturas:

  • Kurose y Ross, secs. 2.2.1–2.2.6 (Overview of HTTP hasta HTTP/2).
  • CS168, HTTP.

Lecturas complementarias:

Temas:

  • Petición y respuesta: métodos, códigos de estado y headers. HTTP no guarda estado; las cookies sí.
  • Conexiones no persistentes y persistentes: cuántos RTT cuesta cada una.
  • Caché: Cache-Control, ETag y peticiones condicionales. Proxies y código 304.
  • HTTP/2: frames, streams, multiplexación y compresión de headers.
  • Error común: creer que HTTP/2 eliminó todo bloqueo. Elimina el de HTTP, pero una pérdida en TCP todavía detiene todos los streams. Por eso existe HTTP/3.

Laboratorio — DNS paso a paso y HTTP por dentro

Trabajen en code/week-12/.

  1. Con dig +trace, seguir la resolución de un nombre .pe. Con dig @servidor +norecurse, repetir el recorrido a mano.
  2. Leer los registros A, AAAA, NS y MX de un dominio. Mirar cómo baja el TTL si se pregunta dos veces al mismo resolver, en la red del laboratorio (en los resolvers públicos no siempre baja de forma ordenada).
  3. Capturar una consulta de DNS y comparar el ID, las preguntas y las respuestas con lo que muestra dig.
  4. Escribir a mano una petición HTTP/1.1 con nc hacia un servidor local. Probar qué pasa si falta Host.
  5. Con curl -v, ver una respuesta 200 y luego una 304 usando If-None-Match.
  6. Medir con curl -w el tiempo de resolución, de conexión y de primera respuesta. Repetir con una conexión reutilizada.
  7. Avanzar con P4.

Algunas actividades se basan en los laboratorios de DNS y HTTP de Kurose y Ross.

Preguntas de repaso

  1. Un resolver sin nada en caché busca www.uni.edu.pe. ¿A qué servidores pregunta y en qué orden? ¿Y la segunda vez?
  2. ¿Qué ventajas y qué riesgos tiene un TTL muy corto?
  3. ¿Cuántos RTT tarda bajar una página con 3 imágenes con conexiones no persistentes y seriales? ¿Y con una conexión persistente?
  4. ¿Qué devuelve un servidor si el ETag no cambió? ¿Qué se ahorra?
  5. ¿Qué bloqueo elimina HTTP/2 y cuál no?

Código de la semana

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

Ejemplos (13)Pruebas (2)Laboratorio (2)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

dns_ask.cDescargar dns_ask.c
// dns_ask: arma a mano una pregunta de DNS, la manda por UDP y muestra el encabezado y las
// respuestas de tipo A. Sirve para ver el formato del RFC 1035 sin ninguna biblioteca.
//   uso: dns_ask <IPv4 del servidor> <nombre> [--norecurse]
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/random.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>

enum { HEADER_SIZE = 12, MAX_MESSAGE = 512, MAX_JUMPS = 32, TYPE_A = 1, CLASS_IN = 1 };

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

static void write_u16(uint8_t *to, uint16_t value) {
    to[0] = (uint8_t)(value >> 8);
    to[1] = (uint8_t)(value & 0xFF);
}

// Escribe "www.uni.test" como 3www3uni4test0. Devuelve los bytes usados, o -1 si no cabe.
static int encode_name(const char *name, uint8_t *out, size_t room) {
    size_t used = 0;
    while (*name != '\0') {
        const char *dot = strchr(name, '.');
        size_t size = dot != NULL ? (size_t)(dot - name) : strlen(name);
        if (size == 0 || size > 63 || used + size + 2 > room) {
            return -1;
        }
        out[used++] = (uint8_t)size;
        memcpy(out + used, name, size);
        used += size;
        name += size;
        if (*name == '.') {
            name++;
        }
    }
    if (used + 1 > room) {
        return -1;
    }
    out[used++] = 0;
    return (int)used;
}

// Se salta un nombre y devuelve dónde sigue. Un puntero ocupa 2 bytes y termina el nombre.
static int skip_name(const uint8_t *message, int length, int offset) {
    while (offset < length) {
        uint8_t size = message[offset];
        if ((size & 0xC0) == 0xC0) {
            return offset + 2 <= length ? offset + 2 : -1;
        }
        if ((size & 0xC0) != 0) {
            return -1;
        }
        if (size == 0) {
            return offset + 1;
        }
        offset += 1 + size;
    }
    return -1;
}

// Imprime un nombre, siguiendo punteros. Solo se aceptan punteros hacia atrás.
static int print_name(const uint8_t *message, int length, int offset) {
    int jumps = 0;
    while (offset < length) {
        uint8_t size = message[offset];
        if ((size & 0xC0) == 0xC0) {
            if (offset + 2 > length || ++jumps > MAX_JUMPS) {
                return -1;
            }
            int target = ((size & 0x3F) << 8) | message[offset + 1];
            if (target >= offset) {
                return -1;
            }
            offset = target;
        } else if ((size & 0xC0) != 0) {
            return -1;
        } else if (size == 0) {
            return 0;
        } else {
            if (offset + 1 + size > length) {
                return -1;
            }
            printf("%.*s.", size, (const char *)message + offset + 1);
            offset += 1 + size;
        }
    }
    return -1;
}

static int print_reply(const uint8_t *reply, int length, uint16_t id) {
    if (length < HEADER_SIZE) {
        fprintf(stderr, "dns_ask: respuesta más corta que el encabezado\n");
        return -1;
    }
    if (read_u16(reply) != id) {
        fprintf(stderr, "dns_ask: el ID no coincide; se descarta\n");
        return -1;
    }
    uint16_t flags = read_u16(reply + 2);
    uint16_t questions = read_u16(reply + 4);
    uint16_t answers = read_u16(reply + 6);
    printf("id=%u qr=%d aa=%d tc=%d rd=%d ra=%d rcode=%d\n", id, (flags >> 15) & 1, (flags >> 10) & 1,
           (flags >> 9) & 1, (flags >> 8) & 1, (flags >> 7) & 1, flags & 0xF);
    printf("preguntas=%u respuestas=%u autoridad=%u adicionales=%u\n", questions, answers,
           read_u16(reply + 8), read_u16(reply + 10));

    int offset = HEADER_SIZE;
    for (int i = 0; i < questions; i++) {
        offset = skip_name(reply, length, offset);
        if (offset < 0 || offset + 4 > length) {
            fprintf(stderr, "dns_ask: pregunta mal formada\n");
            return -1;
        }
        offset += 4;
    }
    for (int i = 0; i < answers; i++) {
        int name_at = offset;
        offset = skip_name(reply, length, offset);
        if (offset < 0 || offset + 10 > length) {
            fprintf(stderr, "dns_ask: respuesta mal formada\n");
            return -1;
        }
        uint16_t type = read_u16(reply + offset);
        uint32_t ttl = ((uint32_t)read_u16(reply + offset + 4) << 16) | read_u16(reply + offset + 6);
        uint16_t size = read_u16(reply + offset + 8);
        offset += 10;
        if (offset + size > length) {
            fprintf(stderr, "dns_ask: datos cortados\n");
            return -1;
        }
        if (type == TYPE_A && size == 4) {
            char text[INET_ADDRSTRLEN];
            if (inet_ntop(AF_INET, reply + offset, text, sizeof text) == NULL) {
                perror("inet_ntop");
                return -1;
            }
            if (print_name(reply, length, name_at) < 0) {
                fprintf(stderr, "dns_ask: nombre mal formado\n");
                return -1;
            }
            printf(" ttl=%u A %s\n", ttl, text);
        }
        offset += size;
    }
    return 0;
}

int main(int argc, char *argv[]) {
    if (argc < 3 || argc > 4 || (argc == 4 && strcmp(argv[3], "--norecurse") != 0)) {
        fprintf(stderr, "uso: %s <IPv4 del servidor> <nombre> [--norecurse]\n", argv[0]);
        return 2;
    }
    struct sockaddr_in server = {.sin_family = AF_INET, .sin_port = htons(53)};
    if (inet_pton(AF_INET, argv[1], &server.sin_addr) != 1) {
        fprintf(stderr, "dns_ask: dirección inválida: %s\n", argv[1]);
        return 2;
    }

    uint16_t id;
    if (getrandom(&id, sizeof id, 0) != (ssize_t)sizeof id) {
        perror("getrandom");
        return 1;
    }

    // Nunca se copia una struct a la red: cada campo se escribe en orden de red.
    uint8_t query[MAX_MESSAGE] = {0};
    write_u16(query, id);
    write_u16(query + 2, argc == 4 ? 0x0000 : 0x0100);  // RD pide recursión
    write_u16(query + 4, 1);                            // una pregunta
    int name_size = encode_name(argv[2], query + HEADER_SIZE, sizeof query - HEADER_SIZE - 4);
    if (name_size < 0) {
        fprintf(stderr, "dns_ask: nombre inválido: %s\n", argv[2]);
        return 2;
    }
    write_u16(query + HEADER_SIZE + name_size, TYPE_A);
    write_u16(query + HEADER_SIZE + name_size + 2, CLASS_IN);
    size_t query_size = (size_t)(HEADER_SIZE + name_size + 4);

    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0) {
        perror("socket");
        return 1;
    }
    struct timeval timeout = {.tv_sec = 2};
    if (setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof timeout) < 0) {
        perror("setsockopt");
        close(sock);
        return 1;
    }
    // Con connect, el kernel solo nos entrega datagramas que vengan de ese servidor.
    if (connect(sock, (struct sockaddr *)&server, sizeof server) < 0) {
        perror("connect");
        close(sock);
        return 1;
    }
    if (send(sock, query, query_size, 0) != (ssize_t)query_size) {
        perror("send");
        close(sock);
        return 1;
    }
    uint8_t reply[MAX_MESSAGE];
    ssize_t got = recv(sock, reply, sizeof reply, 0);
    if (got < 0) {
        fprintf(stderr, "dns_ask: sin respuesta: %s\n", strerror(errno));
        close(sock);
        return 1;
    }
    if (close(sock) < 0) {
        perror("close");
        return 1;
    }
    return print_reply(reply, (int)got, id) == 0 ? 0 : 1;
}
dns_auth.pyDescargar dns_auth.py
#!/usr/bin/env python3
"""Servidor autoritativo mínimo: responde por una zona, por UDP y por TCP.

    python3 dns_auth.py <dirección> <archivo de zona>

Sirve para ver la jerarquía de DNS en una red propia. No es un servidor para usar en serio.
"""
import socket
import struct
import sys
import threading

import dns_wire as w

UDP_LIMIT = 512  # sin EDNS, una respuesta por UDP no pasa de aquí (RFC 1035)


def parse_rdata(rtype, fields):
    if rtype == "MX":
        return int(fields[0]), w.normalize(fields[1])
    if rtype in ("NS", "CNAME"):
        return w.normalize(fields[0])
    if rtype == "SOA":
        return (w.normalize(fields[0]), w.normalize(fields[1]), *(int(field) for field in fields[2:7]))
    if rtype == "TXT":
        text = " ".join(fields).strip('"')
        # Una forma corta de escribir un registro enorme, para ver el truncamiento.
        if text.startswith("repetir:"):
            _tag, count, unit = text.split(":", 2)
            return unit * int(count)
        return text
    return fields[0]


def load_zone(path):
    """Lee una zona: `nombre ttl tipo datos`, un registro por línea y nombres completos."""
    records = []
    with open(path, encoding="utf-8") as file:
        for line in file:
            line = line.split(";", 1)[0].strip()
            if not line:
                continue
            name, ttl, rtype, *fields = line.split()
            records.append(w.Record(w.normalize(name), w.TYPES[rtype], int(ttl), parse_rdata(rtype, fields)))
    soa = [record for record in records if record.rtype == w.TYPES["SOA"]]
    if len(soa) != 1:
        raise ValueError(f"{path}: una zona lleva exactamente un SOA")
    return soa[0].name, records


class Zone:
    def __init__(self, origin, records):
        self.origin = origin
        self.records = records
        self.soa = next(record for record in records if record.rtype == w.TYPES["SOA"])

    def find(self, name, rtype=None):
        return [record for record in self.records
                if record.name == name and (rtype is None or record.rtype == rtype)]

    def name_exists(self, name):
        # Un nombre intermedio, sin registros propios, también existe (RFC 1034, sección 4.3.2).
        return any(record.name == name or record.name.endswith("." + name) for record in self.records)

    def delegation_for(self, name):
        """Devuelve los NS del corte de zona más alto por encima de name, si hay uno."""
        labels = name.rstrip(".").split(".")
        for start in range(len(labels) - 1, -1, -1):
            candidate = ".".join(labels[start:]) + "."
            if candidate == self.origin or not w.in_bailiwick(candidate, self.origin):
                continue
            servers = self.find(candidate, w.TYPES["NS"])
            if servers:
                return servers
        return []

    def glue_for(self, hosts):
        glue = []
        for host in hosts:
            glue += self.find(host, w.TYPES["A"]) + self.find(host, w.TYPES["AAAA"])
        return glue

    def negative(self, reply, rcode):
        # El SOA va en authority: su MINIMUM limita cuánto se recuerda la respuesta negativa.
        ttl = min(self.soa.ttl, self.soa.data[6])
        reply.authority.append(w.Record(self.soa.name, self.soa.rtype, ttl, self.soa.data))
        reply.flags = (reply.flags & ~0xF) | rcode

    def answer(self, reply, name, rtype):
        if not w.in_bailiwick(name, self.origin):
            reply.flags |= w.REFUSED
            return
        servers = self.delegation_for(name)
        if servers:
            # Referral: "yo no sé, pregúntele a estos". No es autoritativa (AA = 0).
            reply.authority += servers
            reply.additional += self.glue_for(record.data for record in servers)
            return
        reply.flags |= w.FLAG_AA
        seen = set()
        while True:
            exact = self.find(name, rtype)
            if exact:
                reply.answer += exact
                break
            alias = self.find(name, w.TYPES["CNAME"])
            if alias and name not in seen:
                seen.add(name)
                reply.answer += alias
                name = alias[0].data
                if not w.in_bailiwick(name, self.origin):
                    return  # el destino es de otra zona: lo sigue el resolver
                continue
            if not reply.answer:
                self.negative(reply, w.NOERROR if self.name_exists(name) else w.NXDOMAIN)
            return
        if rtype == w.TYPES["MX"]:
            reply.additional += self.glue_for(record.data[1] for record in exact)
        elif rtype == w.TYPES["NS"]:
            reply.additional += self.glue_for(record.data for record in exact)


def respond(zone, data, limit):
    """Devuelve la respuesta a un mensaje recibido, o None si no se puede ni contestar."""
    try:
        query = w.decode(data)
    except w.WireError:
        return None, "mensaje mal formado"
    reply = w.Message(query.msg_id, w.FLAG_QR | (query.flags & w.FLAG_RD), question=query.question[:1])
    if len(query.question) != 1:
        reply.flags |= w.FORMERR
        return w.encode(reply), "sin una pregunta"
    name, rtype = query.question[0]
    zone.answer(reply, w.normalize(name), rtype)
    raw = w.encode(reply)
    summary = (f"{w.normalize(name)} {w.TYPE_NAMES.get(rtype, rtype)} -> {w.RCODE_NAMES[reply.rcode]}"
               f" an={len(reply.answer)} ns={len(reply.authority)} ar={len(reply.additional)}")
    if limit and len(raw) > limit:
        # No cabe: mandamos solo la pregunta con TC = 1, y el cliente repite por TCP.
        short = w.Message(query.msg_id, reply.flags | w.FLAG_TC, question=reply.question)
        return w.encode(short), summary + f" TC ({len(raw)} bytes)"
    return raw, summary


def log(transport, peer, msg_id, summary):
    print(f"{transport} {peer[0]}:{peer[1]} id={msg_id} {summary}", flush=True)


def serve_udp(zone, address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((address, 53))
    while True:
        data, peer = sock.recvfrom(4096)
        raw, summary = respond(zone, data, UDP_LIMIT)
        msg_id = struct.unpack_from("!H", data)[0] if len(data) >= 2 else 0
        log("udp", peer, msg_id, summary)
        if raw is not None:
            sock.sendto(raw, peer)


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 serve_tcp_client(zone, conn, peer):
    # Por TCP cada mensaje lleva delante su largo en 2 bytes: un stream no conserva límites.
    with conn:
        conn.settimeout(5)
        try:
            while True:
                head = read_exact(conn, 2)
                if head is None:
                    return
                data = read_exact(conn, struct.unpack("!H", head)[0])
                if data is None:
                    return
                raw, summary = respond(zone, data, None)
                log("tcp", peer, struct.unpack_from("!H", data)[0] if len(data) >= 2 else 0, summary)
                if raw is None:
                    return
                conn.sendall(struct.pack("!H", len(raw)) + raw)
        except OSError:
            return


def serve_tcp(zone, address):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind((address, 53))
    sock.listen(16)
    while True:
        conn, peer = sock.accept()
        threading.Thread(target=serve_tcp_client, args=(zone, conn, peer), daemon=True).start()


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: dns_auth.py <dirección> <archivo de zona>")
    zone = Zone(*load_zone(sys.argv[2]))
    print(f"autoritativo de {zone.origin} en {sys.argv[1]}: {len(zone.records)} registros", flush=True)
    threading.Thread(target=serve_tcp, args=(zone, sys.argv[1]), daemon=True).start()
    serve_udp(zone, sys.argv[1])


if __name__ == "__main__":
    main()
dns_resolver.pyDescargar dns_resolver.py
#!/usr/bin/env python3
"""Resolver recursivo mínimo, con cache: parte de la raíz y baja por las delegaciones.

    python3 dns_resolver.py <dirección> <dirección de la raíz>
"""
import math
import secrets
import socket
import struct
import sys
import threading
import time

import dns_wire as w

MAX_STEPS = 24
QUERY_TIMEOUT = 1.0
UDP_LIMIT = 512


def acceptable_reply(reply, msg_id, question, peer, server):
    """Solo se acepta una respuesta que coincide en servidor, ID y pregunta.

    Quien quiera colar una respuesta falsa tiene que adivinar todo esto, incluido el puerto,
    que aquí revisa el kernel porque el socket está conectado al servidor.
    """
    if peer[0] != server or not reply.flags & w.FLAG_QR or reply.msg_id != msg_id:
        return False
    if len(reply.question) != 1:
        return False
    name, rtype = reply.question[0]
    return w.normalize(name) == w.normalize(question[0]) and rtype == question[1]


def usable_glue(reply, zone):
    """Se guardan solo las direcciones de nombres dentro de la zona delegada."""
    return [record for record in reply.additional
            if record.rtype in (w.TYPES["A"], w.TYPES["AAAA"]) and w.in_bailiwick(record.name, zone)]


class Cache:
    """Guarda conjuntos de registros hasta que vence su TTL."""

    def __init__(self, clock=time.monotonic):
        self.clock = clock
        self.entries = {}
        self.lock = threading.Lock()

    def put(self, name, rtype, records, ttl=None, rcode=w.NOERROR):
        ttl = min((record.ttl for record in records), default=0) if ttl is None else ttl
        if ttl > 0:
            with self.lock:
                self.entries[(name, rtype)] = (self.clock() + ttl, records, rcode)

    def get(self, name, rtype):
        """Devuelve (registros con el TTL que les queda, rcode), o None si no está o venció."""
        with self.lock:
            entry = self.entries.get((name, rtype))
            if entry is None:
                return None
            expires, records, rcode = entry
            now = self.clock()
            if expires <= now:
                del self.entries[(name, rtype)]
                return None
            left = math.ceil(expires - now)
        return [w.Record(r.name, r.rtype, left, r.data) for r in records], rcode


class Resolver:
    def __init__(self, root):
        self.root = root
        self.cache = Cache()

    def ask(self, server, name, rtype):
        """Una pregunta a un servidor, con ID nuevo y un socket (y puerto) nuevo cada vez."""
        msg_id = secrets.randbelow(65536)
        data = w.encode(w.Message(msg_id, 0, question=[(name, rtype)]))
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
            sock.settimeout(QUERY_TIMEOUT)
            sock.connect((server, 53))
            sock.send(data)
            raw = sock.recv(4096)
        reply = w.decode(raw)
        if not acceptable_reply(reply, msg_id, (name, rtype), (server, 53), server):
            raise w.WireError("respuesta que no corresponde a la pregunta")
        if reply.flags & w.FLAG_TC:
            reply = self.ask_tcp(server, name, rtype)
        return reply

    def ask_tcp(self, server, name, rtype):
        msg_id = secrets.randbelow(65536)
        data = w.encode(w.Message(msg_id, 0, question=[(name, rtype)]))
        with socket.create_connection((server, 53), timeout=QUERY_TIMEOUT * 2) as conn:
            conn.sendall(struct.pack("!H", len(data)) + data)
            with conn.makefile("rb") as stream:
                head = stream.read(2)
                if len(head) != 2:
                    raise w.WireError("TCP cerrado antes de la respuesta")
                raw = stream.read(struct.unpack("!H", head)[0])
        reply = w.decode(raw)
        if not acceptable_reply(reply, msg_id, (name, rtype), (server, 53), server):
            raise w.WireError("respuesta TCP que no corresponde a la pregunta")
        return reply

    def closest_servers(self, name):
        """Busca en cache los NS del ancestro más cercano. Sin ninguno, la raíz."""
        labels = name.rstrip(".").split(".")
        for start in range(len(labels)):
            zone = ".".join(labels[start:]) + "."
            cached = self.cache.get(zone, w.TYPES["NS"])
            if not cached:
                continue
            addresses = []
            for record in cached[0]:
                glue = self.cache.get(record.data, w.TYPES["A"])
                if glue:
                    addresses += [r.data for r in glue[0]]
            if addresses:
                return addresses
        return [self.root]

    def resolve(self, name, rtype, depth=0):
        """Devuelve (rcode, registros, authority). Sigue referrals y CNAME."""
        name = w.normalize(name)
        chain = []
        for _ in range(MAX_STEPS):
            cached = self.cache.get(name, rtype)
            if cached:
                records, rcode = cached
                return rcode, chain + records, []
            alias = self.cache.get(name, w.TYPES["CNAME"])
            if alias:
                chain += alias[0]
                name = alias[0][0].data
                continue
            step = self.iterate(name, rtype, depth)
            if step is None:
                return w.SERVFAIL, chain, []
            reply = step
            if reply.rcode == w.NXDOMAIN or (reply.rcode == w.NOERROR and not reply.answer):
                self.remember_negative(name, rtype, reply)
                return reply.rcode, chain, reply.authority
            if reply.rcode != w.NOERROR:
                return w.SERVFAIL, chain, []
            self.remember_answer(reply)
        return w.SERVFAIL, chain, []

    def iterate(self, name, rtype, depth):
        """Pregunta bajando por la jerarquía hasta tener una respuesta final."""
        servers = self.closest_servers(name)
        for _ in range(MAX_STEPS):
            reply = None
            for server in servers:
                try:
                    reply = self.ask(server, name, rtype)
                    break
                except (OSError, w.WireError) as error:
                    print(f"  {server}: {error}", flush=True)
            if reply is None:
                return None
            referral = [r for r in reply.authority if r.rtype == w.TYPES["NS"]]
            if reply.answer or reply.flags & w.FLAG_AA or not referral or reply.rcode != w.NOERROR:
                return reply
            servers = self.follow_referral(reply, referral, depth)
            if not servers:
                return None
        return None

    def follow_referral(self, reply, referral, depth):
        zone = referral[0].name
        self.cache.put(zone, w.TYPES["NS"], referral)
        glue = usable_glue(reply, zone)
        by_name = {}
        for record in glue:
            by_name.setdefault((record.name, record.rtype), []).append(record)
        for (name, rtype), records in by_name.items():
            self.cache.put(name, rtype, records)
        addresses = [record.data for record in glue if record.rtype == w.TYPES["A"]]
        if addresses or depth > 4:
            return addresses
        # Sin glue, el nombre del servidor hay que resolverlo aparte, como cualquier otro.
        for record in referral:
            rcode, records, _ = self.resolve(record.data, w.TYPES["A"], depth + 1)
            if rcode == w.NOERROR:
                addresses += [r.data for r in records if r.rtype == w.TYPES["A"]]
        return addresses

    def remember_answer(self, reply):
        groups = {}
        for record in reply.answer:
            groups.setdefault((record.name, record.rtype), []).append(record)
        for (name, rtype), records in groups.items():
            self.cache.put(name, rtype, records)

    def remember_negative(self, name, rtype, reply):
        soa = [r for r in reply.authority if r.rtype == w.TYPES["SOA"]]
        if soa:
            self.cache.put(name, rtype, [], ttl=min(soa[0].ttl, soa[0].data[6]), rcode=reply.rcode)


def build_reply(resolver, data, peer):
    """Devuelve la respuesta completa a una pregunta, o None si no se puede ni contestar."""
    try:
        query = w.decode(data)
    except w.WireError:
        return None
    reply = w.Message(query.msg_id, w.FLAG_QR | w.FLAG_RA | (query.flags & w.FLAG_RD),
                      question=query.question[:1])
    if len(query.question) != 1:
        reply.flags |= w.FORMERR
        return reply
    name, rtype = query.question[0]
    if query.flags & w.FLAG_RD:
        rcode, records, authority = resolver.resolve(name, rtype)
    else:
        # Sin recursión, un resolver solo cuenta lo que ya tiene en cache.
        cached = resolver.cache.get(w.normalize(name), rtype)
        rcode, records, authority = (cached[1], cached[0], []) if cached else (w.NOERROR, [], [])
    reply.flags |= rcode
    reply.answer = records
    reply.authority = authority
    print(f"{peer[0]}:{peer[1]} id={query.msg_id} {name} {w.TYPE_NAMES.get(rtype, rtype)}"
          f" -> {w.RCODE_NAMES[rcode]} an={len(records)}", flush=True)
    return reply


def handle_udp(resolver, sock, data, peer):
    reply = build_reply(resolver, data, peer)
    if reply is None:
        return
    raw = w.encode(reply)
    if len(raw) > UDP_LIMIT:
        raw = w.encode(w.Message(reply.msg_id, reply.flags | w.FLAG_TC, question=reply.question))
    sock.sendto(raw, peer)


def handle_tcp(resolver, conn, peer):
    with conn:
        conn.settimeout(5)
        try:
            with conn.makefile("rb") as stream:
                head = stream.read(2)
                if len(head) != 2:
                    return
                data = stream.read(struct.unpack("!H", head)[0])
            reply = build_reply(resolver, data, peer)
            if reply is not None:
                raw = w.encode(reply)
                conn.sendall(struct.pack("!H", len(raw)) + raw)
        except OSError:
            return


def serve_tcp(resolver, address):
    listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind((address, 53))
    listener.listen(16)
    while True:
        conn, peer = listener.accept()
        threading.Thread(target=handle_tcp, args=(resolver, conn, peer), daemon=True).start()


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: dns_resolver.py <dirección> <raíz>")
    resolver = Resolver(sys.argv[2])
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((sys.argv[1], 53))
    threading.Thread(target=serve_tcp, args=(resolver, sys.argv[1]), daemon=True).start()
    print(f"resolver en {sys.argv[1]}, raíz {sys.argv[2]}", flush=True)
    while True:
        data, peer = sock.recvfrom(4096)
        threading.Thread(target=handle_udp, args=(resolver, sock, data, peer), daemon=True).start()


if __name__ == "__main__":
    main()
dns_wire.pyDescargar dns_wire.py
"""Formato de los mensajes de DNS (RFC 1035, sección 4), byte por byte y sin bibliotecas."""
import struct
from dataclasses import dataclass, field

TYPES = {"A": 1, "NS": 2, "CNAME": 5, "SOA": 6, "MX": 15, "TXT": 16, "AAAA": 28}
TYPE_NAMES = {number: name for name, number in TYPES.items()}
CLASS_IN = 1

# Bits del segundo campo del encabezado.
FLAG_QR = 0x8000  # 1 si es respuesta
FLAG_AA = 0x0400  # respuesta autoritativa
FLAG_TC = 0x0200  # truncada: pregunte por TCP
FLAG_RD = 0x0100  # el cliente pide recursión
FLAG_RA = 0x0080  # el servidor ofrece recursión

NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED = range(6)
RCODE_NAMES = ["NOERROR", "FORMERR", "SERVFAIL", "NXDOMAIN", "NOTIMP", "REFUSED"]

MAX_POINTER_JUMPS = 32


class WireError(ValueError):
    """El mensaje no respeta el formato. Nunca confiamos en lo que llega de la red."""


@dataclass
class Record:
    name: str
    rtype: int
    ttl: int
    data: object  # depende del tipo; ver encode_rdata

    def __str__(self):
        return f"{self.name} {self.ttl} IN {TYPE_NAMES.get(self.rtype, self.rtype)} {self.data}"


@dataclass
class Message:
    msg_id: int = 0
    flags: int = 0
    question: list = field(default_factory=list)  # lista de (nombre, tipo)
    answer: list = field(default_factory=list)
    authority: list = field(default_factory=list)
    additional: list = field(default_factory=list)

    @property
    def rcode(self):
        return self.flags & 0x000F


def normalize(name):
    """Los nombres no distinguen mayúsculas; los guardamos en minúsculas y con punto final."""
    name = name.lower()
    return name if name.endswith(".") else name + "."


def encode_name(name, buffer, offsets):
    """Agrega un nombre a buffer. Si ya apareció un sufijo, usa un puntero a él."""
    labels = [label for label in name.split(".") if label]
    for index, label in enumerate(labels):
        suffix = ".".join(labels[index:]).lower()
        if suffix in offsets:
            buffer += struct.pack("!H", 0xC000 | offsets[suffix])
            return
        if len(buffer) < 0x4000:
            offsets[suffix] = len(buffer)
        raw = label.encode("ascii")
        if not 1 <= len(raw) <= 63:
            raise WireError(f"etiqueta de largo inválido: {label!r}")
        buffer.append(len(raw))
        buffer += raw
    buffer.append(0)


def decode_name(data, offset):
    """Lee un nombre, siguiendo punteros. Devuelve el nombre y dónde sigue el mensaje."""
    labels = []
    end = None
    jumps = 0
    while True:
        if offset >= len(data):
            raise WireError("nombre cortado")
        size = data[offset]
        if size & 0xC0 == 0xC0:
            if offset + 1 >= len(data):
                raise WireError("puntero cortado")
            if end is None:
                end = offset + 2
            target = ((size & 0x3F) << 8) | data[offset + 1]
            # Un puntero solo puede ir hacia atrás. Eso, y el límite, evitan los ciclos.
            jumps += 1
            if target >= offset or jumps > MAX_POINTER_JUMPS:
                raise WireError("puntero inválido")
            offset = target
        elif size & 0xC0:
            raise WireError("etiqueta de tipo desconocido")
        elif size == 0:
            if end is None:
                end = offset + 1
            return (".".join(labels) + ".") if labels else ".", end
        else:
            if offset + 1 + size > len(data):
                raise WireError("etiqueta cortada")
            labels.append(data[offset + 1:offset + 1 + size].decode("ascii", "replace"))
            offset += 1 + size


def encode_ipv4(text):
    parts = [int(part) for part in text.split(".")]
    if len(parts) != 4 or not all(0 <= part <= 255 for part in parts):
        raise WireError(f"dirección inválida: {text}")
    return bytes(parts)


def encode_rdata(record, buffer, offsets):
    """Agrega los datos del registro. Deja el RDLENGTH correcto antes de ellos."""
    length_at = len(buffer)
    buffer += b"\0\0"
    kind = record.rtype
    if kind == TYPES["A"]:
        buffer += encode_ipv4(record.data)
    elif kind == TYPES["AAAA"]:
        import ipaddress
        buffer += ipaddress.IPv6Address(record.data).packed
    elif kind in (TYPES["NS"], TYPES["CNAME"]):
        encode_name(record.data, buffer, offsets)
    elif kind == TYPES["MX"]:
        preference, host = record.data
        buffer += struct.pack("!H", preference)
        encode_name(host, buffer, offsets)
    elif kind == TYPES["SOA"]:
        mname, rname, serial, refresh, retry, expire, minimum = record.data
        encode_name(mname, buffer, offsets)
        encode_name(rname, buffer, offsets)
        buffer += struct.pack("!5I", serial, refresh, retry, expire, minimum)
    elif kind == TYPES["TXT"]:
        # Un TXT es una o más cadenas de hasta 255 bytes, cada una con su largo.
        raw = record.data.encode("utf-8")
        chunks = [raw[start:start + 255] for start in range(0, len(raw), 255)] or [b""]
        for chunk in chunks:
            buffer.append(len(chunk))
            buffer += chunk
    else:
        raise WireError(f"tipo no soportado: {kind}")
    struct.pack_into("!H", buffer, length_at, len(buffer) - length_at - 2)


def encode(message):
    """Convierte un mensaje a bytes, con todos los campos en orden de red."""
    buffer = bytearray(struct.pack(
        "!6H", message.msg_id, message.flags, len(message.question), len(message.answer),
        len(message.authority), len(message.additional)))
    offsets = {}
    for name, rtype in message.question:
        encode_name(name, buffer, offsets)
        buffer += struct.pack("!2H", rtype, CLASS_IN)
    for record in message.answer + message.authority + message.additional:
        encode_name(record.name, buffer, offsets)
        buffer += struct.pack("!HHI", record.rtype, CLASS_IN, record.ttl)
        encode_rdata(record, buffer, offsets)
    return bytes(buffer)


def decode_rdata(data, offset, length, rtype):
    end = offset + length
    if rtype == TYPES["A"] and length == 4:
        return ".".join(str(byte) for byte in data[offset:end])
    if rtype == TYPES["AAAA"] and length == 16:
        import ipaddress
        return str(ipaddress.IPv6Address(data[offset:end]))
    if rtype in (TYPES["NS"], TYPES["CNAME"]):
        return decode_name(data, offset)[0]
    if rtype == TYPES["MX"] and length >= 3:
        return struct.unpack_from("!H", data, offset)[0], decode_name(data, offset + 2)[0]
    if rtype == TYPES["SOA"]:
        mname, cursor = decode_name(data, offset)
        rname, cursor = decode_name(data, cursor)
        if cursor + 20 > end:
            raise WireError("SOA cortado")
        return (mname, rname, *struct.unpack_from("!5I", data, cursor))
    if rtype == TYPES["TXT"]:
        chunks, cursor = [], offset
        while cursor < end:
            size = data[cursor]
            chunks.append(data[cursor + 1:cursor + 1 + size])
            cursor += 1 + size
        return b"".join(chunks).decode("utf-8", "replace")
    # Tipos que no entendemos (por ejemplo OPT, de EDNS): se conservan como bytes.
    return bytes(data[offset:end])


def decode_record(data, offset):
    name, offset = decode_name(data, offset)
    if offset + 10 > len(data):
        raise WireError("registro cortado")
    rtype, _rclass, ttl, length = struct.unpack_from("!HHIH", data, offset)
    offset += 10
    if offset + length > len(data):
        raise WireError("datos del registro cortados")
    return Record(normalize(name), rtype, ttl, decode_rdata(data, offset, length, rtype)), offset + length


def decode(data):
    """Convierte bytes en un mensaje. Lanza WireError si algo no cuadra."""
    if len(data) < 12:
        raise WireError("mensaje más corto que el encabezado")
    msg_id, flags, qd, an, ns, ar = struct.unpack_from("!6H", data, 0)
    message = Message(msg_id, flags)
    offset = 12
    for _ in range(qd):
        name, offset = decode_name(data, offset)
        if offset + 4 > len(data):
            raise WireError("pregunta cortada")
        rtype, _rclass = struct.unpack_from("!2H", data, offset)
        offset += 4
        message.question.append((name, rtype))
    for section, count in ((message.answer, an), (message.authority, ns), (message.additional, ar)):
        for _ in range(count):
            record, offset = decode_record(data, offset)
            section.append(record)
    return message


def in_bailiwick(name, zone):
    """¿El nombre cae dentro de la zona? Quien responde solo tiene autoridad sobre su zona."""
    name, zone = normalize(name), normalize(zone)
    return zone == "." or name == zone or name.endswith("." + zone)
h2_frames.pyDescargar h2_frames.py
#!/usr/bin/env python3
"""Mira HTTP/2 por dentro: acepta una conexión, lista cada frame y responde por varios streams.

    python3 h2_frames.py <dirección> <puerto>

No es un servidor de HTTP/2: no valida el protocolo ni decodifica los headers (HPACK). Solo le
contesta lo justo a `curl --http2-prior-knowledge`, mandando un DATA de cada stream abierto por
turno, para que se vea la multiplexación en la captura.
"""
import socket
import struct
import sys
import threading

PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
FRAME_NAMES = {0: "DATA", 1: "HEADERS", 3: "RST_STREAM", 4: "SETTINGS", 6: "PING", 7: "GOAWAY",
               8: "WINDOW_UPDATE"}
END_STREAM, ACK, END_HEADERS = 0x1, 0x1, 0x4
CHUNK = 400
CHUNKS = 5
TURN_SECONDS = 0.1
# En HPACK, 0x88 es el índice 8 de la tabla estática: ":status: 200". Un solo byte.
STATUS_200 = b"\x88"


def frame(kind, flags, stream, payload=b""):
    """Un frame: 3 bytes de largo, tipo, flags, y 4 bytes de stream (RFC 9113, sección 4.1)."""
    return struct.pack("!I", len(payload))[1:] + bytes([kind, flags]) + struct.pack("!I", stream) + payload


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 read_frame(conn):
    head = read_exact(conn, 9)
    if head is None:
        return None
    length = struct.unpack("!I", b"\0" + head[:3])[0]
    stream = struct.unpack("!I", head[5:9])[0] & 0x7FFFFFFF
    payload = read_exact(conn, length)
    return None if payload is None else (head[3], head[4], stream, payload)


def send_turn(conn, pending):
    """Manda un DATA de cada stream abierto: así se turnan dentro de la misma conexión."""
    for stream in list(pending):
        pending[stream] -= 1
        last = END_STREAM if pending[stream] == 0 else 0
        conn.sendall(frame(0, last, stream, bytes([65 + stream % 26]) * CHUNK))
        print(f"-> DATA stream={stream} largo={CHUNK}{' END_STREAM' if last else ''}", flush=True)
        if last:
            del pending[stream]


def serve(conn):
    if read_exact(conn, len(PREFACE)) != PREFACE:
        print("no empezó con el prefacio de HTTP/2", flush=True)
        return
    print("<- prefacio de HTTP/2", flush=True)
    conn.sendall(frame(4, 0, 0))
    conn.settimeout(TURN_SECONDS)
    pending = {}
    while True:
        try:
            received = read_frame(conn)
        except socket.timeout:
            # Curl no tiene más que decir por ahora: toca otro turno de datos.
            send_turn(conn, pending)
            continue
        except OSError:
            return
        if received is None:
            return
        kind, flags, stream, payload = received
        print(f"<- {FRAME_NAMES.get(kind, kind)} stream={stream} largo={len(payload)}", flush=True)
        if kind == 4 and not flags & ACK:
            conn.sendall(frame(4, ACK, 0))
        elif kind == 6 and not flags & ACK:
            conn.sendall(frame(6, ACK, 0, payload))
        elif kind == 1:
            conn.sendall(frame(1, END_HEADERS, stream, STATUS_200))
            print(f"-> HEADERS stream={stream} largo=1", flush=True)
            pending[stream] = CHUNKS
        elif kind == 7:
            return


def serve_and_close(conn):
    with conn:
        serve(conn)


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: h2_frames.py <dirección> <puerto>")
    listener = socket.socket()
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind((sys.argv[1], int(sys.argv[2])))
    listener.listen(4)
    print(f"h2_frames en {sys.argv[1]}:{sys.argv[2]}", flush=True)
    while True:
        conn, _peer = listener.accept()
        conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        threading.Thread(target=serve_and_close, args=(conn,), daemon=True).start()


if __name__ == "__main__":
    main()
mini_cache.pyDescargar mini_cache.py
#!/usr/bin/env python3
"""Un proxy con cache, para ver max-age, Age y la revalidación con ETag.

    python3 mini_cache.py <dirección> <puerto>

El cliente lo usa con `curl -x`. Solo entiende GET por HTTP sin cifrar, y guarda en memoria.
Cada respuesta lleva X-Cache: HIT (no tocó el origen), REVALIDATED (preguntó y recibió 304),
MISS (bajó todo) o BYPASS (no se puede guardar).
"""
import http.client
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit

store = {}
lock = threading.Lock()
HOP_BY_HOP = {"connection", "keep-alive", "proxy-connection", "transfer-encoding", "te", "upgrade"}


def cache_directives(value):
    """Convierte `max-age=60, no-cache` en {"max-age": "60", "no-cache": ""}."""
    directives = {}
    for item in (value or "").split(","):
        name, _, argument = item.strip().partition("=")
        if name:
            directives[name.lower()] = argument
    return directives


def freshness_lifetime(cache_control):
    """Cuántos segundos está fresca una respuesta, o 0 si hay que revalidar siempre."""
    directives = cache_directives(cache_control)
    if "no-cache" in directives:
        return 0
    try:
        return max(0, int(directives.get("max-age", "0")))
    except ValueError:
        return 0


def storable(status, cache_control, etag):
    """Aquí guardamos un 200 si trae max-age o ETag, y si no lo prohíbe no-store."""
    directives = cache_directives(cache_control)
    if status != 200 or "no-store" in directives or "private" in directives:
        return False
    return freshness_lifetime(cache_control) > 0 or etag is not None


class Entry:
    def __init__(self, status, headers, body, now):
        self.status = status
        self.headers = headers
        self.body = body
        self.stored_at = now

    def header(self, name):
        return next((value for key, value in self.headers if key.lower() == name), None)

    def age(self, now):
        return int(now - self.stored_at)

    def fresh(self, now):
        return self.age(now) < freshness_lifetime(self.header("cache-control"))


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "mini_cache"
    disable_nagle_algorithm = True

    def version_string(self):
        return self.server_version

    def log_message(self, format, *args):
        pass

    def fetch(self, parts, extra_headers=()):
        conn = http.client.HTTPConnection(parts.hostname, parts.port or 80, timeout=5)
        try:
            path = parts.path or "/"
            headers = {"Host": parts.netloc, "Via": "1.1 mini_cache", **dict(extra_headers)}
            conn.request("GET", path, headers=headers)
            response = conn.getresponse()
            kept = [(key, value) for key, value in response.getheaders()
                    if key.lower() not in HOP_BY_HOP | {"content-length"}]
            return response.status, kept, response.read()
        finally:
            conn.close()

    def forward(self, status, headers, body, state, age=None):
        self.send_response_only(status)
        for name, value in headers:
            if name.lower() != "age":
                self.send_header(name, value)
        if age is not None:
            self.send_header("Age", str(age))
        self.send_header("Via", "1.1 mini_cache")
        self.send_header("X-Cache", state)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        print(f"{state} {self.path}", flush=True)

    def do_GET(self):
        parts = urlsplit(self.path)
        if parts.scheme != "http" or not parts.hostname:
            self.send_error(400, "use una URL completa: esto es un proxy")
            return
        key = self.path
        now = time.monotonic()
        with lock:
            entry = store.get(key)
        try:
            if entry and entry.fresh(now):
                return self.forward(entry.status, entry.headers, entry.body, "HIT", entry.age(now))
            conditional = []
            if entry and entry.header("etag"):
                conditional = [("If-None-Match", entry.header("etag"))]
            status, headers, body = self.fetch(parts, conditional)
        except OSError as error:
            self.send_error(502, f"el origen no responde: {error}")
            return
        now = time.monotonic()
        if status == 304 and entry:
            # El origen confirma que sigue igual: se renueva sin volver a bajar el cuerpo.
            with lock:
                entry.stored_at = now
            return self.forward(entry.status, entry.headers, entry.body, "REVALIDATED", 0)
        lookup = dict((name.lower(), value) for name, value in headers)
        if storable(status, lookup.get("cache-control"), lookup.get("etag")):
            with lock:
                store[key] = Entry(status, headers, body, now)
            return self.forward(status, headers, body, "MISS")
        with lock:
            store.pop(key, None)
        return self.forward(status, headers, body, "BYPASS")


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: mini_cache.py <dirección> <puerto>")
    server = ThreadingHTTPServer((sys.argv[1], int(sys.argv[2])), Handler)
    print(f"mini_cache en {sys.argv[1]}:{sys.argv[2]}", flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()
mini_web.pyDescargar mini_web.py
#!/usr/bin/env python3
"""Un sitio web de juguete, en memoria, para mirar HTTP/1.1 por dentro.

    python3 mini_web.py <dirección> <puerto>

No lee archivos del disco. Sirve dos sitios en la misma dirección, para ver para qué existe Host,
y cuenta las peticiones y las conexiones. A propósito no valida las peticiones como un servidor de
verdad: por ejemplo, acepta una petición HTTP/1.1 sin Host.
"""
import hashlib
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SITES = {
    "www.wayra.test": {
        "/": b"<html><img src=/a.png><img src=/b.png><img src=/c.png>wayra</html>\n",
        "/a.png": b"A" * 900,
        "/b.png": b"B" * 900,
        "/c.png": b"C" * 900,
    },
    "blog.wayra.test": {"/": b"<html>el blog, en la misma IP</html>\n"},
}
DEFAULT_SITE = "www.wayra.test"
# Una página que cambia, para ver el fin de un ETag.
versions = {"/noticia": 1}
counters = {"requests": 0, "connections": 0}
lock = threading.Lock()


def etag_of(body):
    return '"' + hashlib.sha256(body).hexdigest()[:12] + '"'


class Handler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"  # persistentes por defecto
    server_version = "mini_web"
    # Encabezado y cuerpo salen en dos write. Sin esto, Nagle más el ACK retardado del cliente
    # agregarían unos 40 ms que no tienen nada que ver con lo que queremos medir.
    disable_nagle_algorithm = True

    def setup(self):
        super().setup()
        with lock:
            counters["connections"] += 1

    def version_string(self):
        return self.server_version

    def log_message(self, format, *args):
        print(f"{self.client_address[0]}:{self.client_address[1]} {format % args}", flush=True)

    def reply(self, status, body=b"", headers=()):
        self.send_response(status)
        for name, value in headers:
            self.send_header(name, value)
        # Un 304 no lleva cuerpo, y por eso tampoco necesita decir su largo.
        if status != 304:
            self.send_header("Content-Length", str(len(body)))
        # Si el cliente pidió cerrar, se lo confirmamos. send_header además marca el cierre.
        if self.headers.get("Connection", "").lower() == "close":
            self.send_header("Connection", "close")
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def do_HEAD(self):
        self.do_GET()

    def do_GET(self):
        with lock:
            counters["requests"] += 1
        if self.path == "/contadores":
            body = f"{counters['requests']} {counters['connections']}\n".encode()
            return self.reply(200, body, [("Cache-Control", "no-store")])
        if self.path == "/cambiar":
            with lock:
                versions["/noticia"] += 1
            return self.reply(200, b"cambiada\n", [("Cache-Control", "no-store")])
        if self.path == "/hora":
            # Sin ninguna indicación de cache.
            return self.reply(200, f"{time.time():.3f}\n".encode())
        if self.path == "/noticia":
            body = f"<html>noticia, versión {versions['/noticia']}</html>\n".encode()
            return self.conditional(body, "max-age=3")
        if self.path == "/visitas":
            # El servidor no recuerda a nadie: el estado viaja en la cookie, ida y vuelta.
            visits = int(self.cookie("visitas") or 0) + 1
            return self.reply(200, f"visita {visits}\n".encode(), [
                ("Set-Cookie", f"visitas={visits}; Path=/"), ("Cache-Control", "no-store")])
        if self.path == "/privado":
            return self.reply(200, b"su saldo\n", [("Cache-Control", "no-store")])
        host = (self.headers.get("Host") or DEFAULT_SITE).split(":")[0]
        body = SITES.get(host, SITES[DEFAULT_SITE]).get(self.path)
        if body is None:
            return self.reply(404, b"no existe\n")
        kind = "image/png" if self.path.endswith(".png") else "text/html"
        return self.conditional(body, "max-age=60", [("Content-Type", kind)])

    def cookie(self, name):
        for item in (self.headers.get("Cookie") or "").split(";"):
            key, _, value = item.strip().partition("=")
            if key == name and value.isdigit():
                return value
        return None

    def conditional(self, body, cache_control, extra=()):
        etag = etag_of(body)
        headers = [("ETag", etag), ("Cache-Control", cache_control), *extra]
        if self.headers.get("If-None-Match") == etag:
            return self.reply(304, b"", headers)
        return self.reply(200, body, headers)


def main():
    if len(sys.argv) != 3:
        sys.exit("uso: mini_web.py <dirección> <puerto>")
    server = ThreadingHTTPServer((sys.argv[1], int(sys.argv[2])), Handler)
    print(f"mini_web en {sys.argv[1]}:{sys.argv[2]}", flush=True)
    server.serve_forever()


if __name__ == "__main__":
    main()
netlab.pyDescargar netlab.py
"""Ayudas de las pruebas de la semana 12: levantar la red 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 = "solution" if os.path.isdir(os.path.join(HERE, "solution")) else "lab"


def require_answers(*names):
    """Devuelve la carpeta con las zonas del laboratorio, o salta si siguen con el TODO."""
    for name in names:
        with open(os.path.join(HERE, ANSWERS, name), encoding="utf-8") as file:
            if "TODO" in file.read():
                raise unittest.SkipTest(f"falta completar {ANSWERS}/{name}")
    return ANSWERS


def run(command, timeout=60):
    return subprocess.run(command, capture_output=True, text=True, timeout=timeout)


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.2):
    """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()
red/red.clab.ymlDescargar red/red.clab.yml
# Un Internet de bolsillo para DNS y HTTP. Todos cuelgan de r; cada servidor está a unos
# milisegundos, para que se note cuántas idas y vueltas cuesta cada cosa.
#
#     cliente   resolver   raiz    tld     auth     web     cache    killa
#     10.212.1  10.212.2   .3      .4      .5       .6      .7       .8        (.10 cada host)
#         \        |        |       |       |        |       |        /
#          \_______|________|_______r_______|________|_______|_______/
#
# RTT desde r: raiz, tld, auth y killa 20 ms; web 50 ms; cliente, resolver y cache casi nada.
name: w12-red
mgmt:
  network: redes-w12-red
  ipv4-subnet: 172.90.12.0/26
  ipv6-subnet: 3fff:172:90:12::/80
topology:
  defaults:
    kind: linux
    image: redes-router
    binds: [..:/w12:ro]
  nodes:
    r:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - sysctl -w net.ipv4.ip_forward=1
        - ip addr add 10.212.1.1/24 dev eth1
        - ip addr add 10.212.2.1/24 dev eth2
        - ip addr add 10.212.3.1/24 dev eth3
        - ip addr add 10.212.4.1/24 dev eth4
        - ip addr add 10.212.5.1/24 dev eth5
        - ip addr add 10.212.6.1/24 dev eth6
        - ip addr add 10.212.7.1/24 dev eth7
        - ip addr add 10.212.8.1/24 dev eth8
        - tc qdisc add dev eth3 root netem delay 10ms
        - tc qdisc add dev eth4 root netem delay 10ms
        - tc qdisc add dev eth5 root netem delay 10ms
        - tc qdisc add dev eth6 root netem delay 25ms
        - tc qdisc add dev eth8 root netem delay 10ms
    cliente:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.1.10/24 dev eth1
        - ip route add default via 10.212.1.1
    resolver:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.2.10/24 dev eth1
        - ip route add default via 10.212.2.1
    raiz:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.3.10/24 dev eth1
        - ip route add default via 10.212.3.1
        - tc qdisc add dev eth1 root netem delay 10ms
    tld:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.4.10/24 dev eth1
        - ip route add default via 10.212.4.1
        - tc qdisc add dev eth1 root netem delay 10ms
    auth:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.5.10/24 dev eth1
        - ip route add default via 10.212.5.1
        - tc qdisc add dev eth1 root netem delay 10ms
    web:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.6.10/24 dev eth1
        - ip route add default via 10.212.6.1
        - tc qdisc add dev eth1 root netem delay 25ms
    cache:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.7.10/24 dev eth1
        - ip route add default via 10.212.7.1
    killa:
      exec:
        - ip route del default
        - sysctl -w net.ipv6.conf.all.disable_ipv6=1
        - ip addr add 10.212.8.10/24 dev eth1
        - ip route add default via 10.212.8.1
        - tc qdisc add dev eth1 root netem delay 10ms
  # Containerlab usa MTU 9500 por defecto; aquí queremos el valor de Ethernet.
  links:
    - {endpoints: ["cliente:eth1", "r:eth1"], mtu: 1500}
    - {endpoints: ["resolver:eth1", "r:eth2"], mtu: 1500}
    - {endpoints: ["raiz:eth1", "r:eth3"], mtu: 1500}
    - {endpoints: ["tld:eth1", "r:eth4"], mtu: 1500}
    - {endpoints: ["auth:eth1", "r:eth5"], mtu: 1500}
    - {endpoints: ["web:eth1", "r:eth6"], mtu: 1500}
    - {endpoints: ["cache:eth1", "r:eth7"], mtu: 1500}
    - {endpoints: ["killa:eth1", "r:eth8"], mtu: 1500}
red/start.shDescargar red/start.sh
#!/bin/sh
# Arranca los servidores de la red, ya desplegada. Se corre desde el contenedor del curso.
#   sh red/start.sh            todos los servidores
#   sh red/start.sh lab <dir>  además, el TLD y killa con las zonas de <dir>, relativo a /w12
set -eu
p=clab-w12-red-
run() { docker exec -d "$p$1" sh -c "cd /w12 && exec python3 -u $2 >/tmp/$1.log 2>&1"; }

tld_zone=red/zones/test.zone
if [ "${1:-}" = lab ]; then
    tld_zone="$2/test.zone"
    run killa "dns_auth.py 10.212.8.10 $2/killa.zone"
fi
run raiz "dns_auth.py 10.212.3.10 red/zones/root.zone"
run tld "dns_auth.py 10.212.4.10 $tld_zone"
run auth "dns_auth.py 10.212.5.10 red/zones/wayra.zone"
run resolver "dns_resolver.py 10.212.2.10 10.212.3.10"
run web "mini_web.py 10.212.6.10 80"
run cache "mini_cache.py 10.212.7.10 3128"
# Cliente y proxy le preguntan a nuestro resolver: es lo que haría DHCP en una red real.
for node in cliente cache; do
    docker exec "$p$node" sh -c 'echo "nameserver 10.212.2.10" > /etc/resolv.conf'
done
# Python tarda unos segundos en cargar los módulos: no devolvemos el control hasta que todos escuchen.
for node in raiz tld auth resolver web cache; do
    tries=0
    until docker exec "$p$node" grep -q " en 10.212" "/tmp/$node.log" 2>/dev/null; do
        tries=$((tries + 1))
        if [ "$tries" -gt 100 ]; then
            echo "$node no arrancó" >&2
            docker exec "$p$node" cat "/tmp/$node.log" >&2
            exit 1
        fi
        sleep 0.2
    done
done
red/zones/root.zoneDescargar red/zones/root.zone
; La raíz de nuestro mundo de laboratorio. .test está reservado para pruebas (RFC 6761).
. 86400 SOA a.raiz.test. admin.raiz.test. 2026110201 3600 600 86400 300
. 86400 NS a.raiz.test.
; Delegación de test. con su glue
test. 86400 NS ns1.nic.test.
ns1.nic.test. 86400 A 10.212.4.10
a.raiz.test. 86400 A 10.212.3.10
red/zones/test.zoneDescargar red/zones/test.zone
; El TLD. No conoce los nombres de abajo, solo a quién preguntarle por cada dominio.
test. 3600 SOA ns1.nic.test. admin.nic.test. 2026110201 3600 600 86400 300
test. 3600 NS ns1.nic.test.
ns1.nic.test. 3600 A 10.212.4.10
a.raiz.test. 86400 A 10.212.3.10
; Delegación de wayra.test. con su glue
wayra.test. 3600 NS ns1.wayra.test.
ns1.wayra.test. 3600 A 10.212.5.10
red/zones/wayra.zoneDescargar red/zones/wayra.zone
; El servidor autoritativo de wayra.test. Aquí sí están las respuestas finales.
wayra.test. 3600 SOA ns1.wayra.test. admin.wayra.test. 2026110201 3600 600 86400 60
wayra.test. 3600 NS ns1.wayra.test.
ns1.wayra.test. 3600 A 10.212.5.10
www.wayra.test. 300 A 10.212.6.10
www.wayra.test. 300 AAAA 2001:db8:212:6::10
blog.wayra.test. 300 CNAME www.wayra.test.
wayra.test. 3600 MX 10 correo.wayra.test.
correo.wayra.test. 3600 A 10.212.6.25
wayra.test. 3600 TXT "curso de redes: nombres de ejemplo"
; TTL corto, para ver cómo vence
rapido.wayra.test. 4 A 10.212.6.11
; No cabe en 512 bytes: obliga a repetir por TCP
grande.wayra.test. 300 TXT "repetir:40:registro-de-relleno-para-pasar-de-512-bytes;"

Pruebas

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

test_net.pyDescargar test_net.py
"""Pruebas sobre la red: cada afirmación de los apuntes que se puede comprobar corriéndola."""
import os
import re
import tempfile
import time
import unittest

from netlab import HERE, Lab, require_answers, run, wait_until

LAB = Lab("red", "w12-red", "redes-w12-red")
RESOLVER, ROOT, TLD, AUTH = "10.212.2.10", "10.212.3.10", "10.212.4.10", "10.212.5.10"
WEB = "10.212.6.10"
PROXY = "10.212.7.10:3128"
ATTEMPTS = 6
URL = "http://www.wayra.test"
SERVERS = {"raiz": ROOT, "tld": TLD, "auth": AUTH, "resolver": RESOLVER, "web": WEB}


def setUpModule():
    unittest.addModuleCleanup(LAB.destroy)
    LAB.deploy()
    done = run(["sh", os.path.join(HERE, "red", "start.sh")])
    if done.returncode != 0:
        raise RuntimeError(done.stdout + done.stderr)
    for node in ("raiz", "tld", "auth", "resolver", "web", "cache"):
        if not wait_until(lambda: "en 10.212" in LAB.out(node, "cat", f"/tmp/{node}.log"), 20):
            raise RuntimeError(f"{node} no arrancó: " + LAB.out(node, "cat", f"/tmp/{node}.log"))


def dig(*args):
    return LAB.out("cliente", "dig", "+time=3", "+tries=1", *args)


def answers(*args):
    return LAB.out("cliente", "dig", "+short", "+time=3", "+tries=1", *args).split()


def query_time(*args):
    return int(re.search(r"Query time: (\d+) msec", dig(*args)).group(1))


def log_of(node):
    return LAB.out(node, "cat", f"/tmp/{node}.log")


def restart_resolver():
    """Un resolver nuevo no tiene nada en cache."""
    LAB.sh("resolver", "pkill", "-f", "dns_resolver")
    wait_until(lambda: "dns_resolver" not in LAB.out("resolver", "ps"), 5)
    LAB.daemon("resolver", f"cd /w12 && exec python3 -u dns_resolver.py {RESOLVER} {ROOT} "
                           ">/tmp/resolver.log 2>&1")
    if not wait_until(lambda: "resolver en" in log_of("resolver"), 10):
        raise RuntimeError(log_of("resolver"))


def curl(*args):
    return LAB.out("cliente", "curl", "-s", "--max-time", "10", *args)


def capture(node, name, seconds, expression):
    """Captura en segundo plano, en el nodo. Devuelve cuando tcpdump ya está escuchando."""
    LAB.daemon(node, f"timeout {seconds} tcpdump -i eth1 --immediate-mode -U -w /tmp/{name}.pcap {expression} "
                     f">/tmp/{name}.err 2>&1")
    if not wait_until(lambda: "listening" in LAB.out(node, "cat", f"/tmp/{name}.err"), 5):
        raise RuntimeError("tcpdump no arrancó")


def tshark(node, name, *args):
    """Lee la captura de un nodo con tshark, que corre en el contenedor del curso."""
    with tempfile.TemporaryDirectory() as folder:
        local = os.path.join(folder, "x.pcap")
        copied = run(["docker", "cp", "-q", f"{LAB.prefix}{node}:/tmp/{name}.pcap", local])
        if copied.returncode != 0:
            raise RuntimeError(copied.stderr)
        return run(["tshark", "-r", local, *args]).stdout


class A_Hierarchy(unittest.TestCase):
    def test_trace_goes_root_tld_auth(self):
        out = dig("+trace", "www.wayra.test")
        where = [out.index(f"from {ip}#53") for ip in (ROOT, TLD, AUTH)]
        self.assertEqual(where, sorted(where), out)
        self.assertIn("10.212.6.10", out)

    def test_manual_walk(self):
        root = dig(f"@{ROOT}", "+norecurse", "www.wayra.test")
        self.assertIn("ANSWER: 0", root)
        self.assertIn(TLD, root)
        tld = dig(f"@{TLD}", "+norecurse", "www.wayra.test")
        self.assertIn("ANSWER: 0", tld)
        self.assertIn(AUTH, tld)
        auth = dig(f"@{AUTH}", "+norecurse", "www.wayra.test")
        self.assertRegex(auth, r"flags: qr aa;")
        self.assertIn("ANSWER: 1", auth)

    def test_cold_query_walks_and_second_one_stays_in_the_resolver(self):
        restart_resolver()
        before = {node: log_of(node).count("www.wayra.test.") for node in ("raiz", "tld", "auth")}
        cold = query_time("www.wayra.test")
        after_cold = {node: log_of(node).count("www.wayra.test.") for node in before}
        for node in before:
            self.assertEqual(after_cold[node], before[node] + 1, node)
        warm = min(query_time("www.wayra.test") for _ in range(3))
        self.assertEqual({node: log_of(node).count("www.wayra.test.") for node in before}, after_cold)
        # Tres RTT de 20 ms contra casi nada. El margen es grande porque la máquina es compartida.
        self.assertGreaterEqual(cold, 50)
        self.assertLess(warm, cold / 2)

    def test_after_learning_the_tld_a_new_name_skips_the_root(self):
        restart_resolver()
        answers("www.wayra.test")
        before = log_of("raiz").count("\n")
        self.assertEqual(answers("correo.wayra.test"), ["10.212.6.25"])
        self.assertEqual(log_of("raiz").count("\n"), before)


class B_Records(unittest.TestCase):
    def test_types(self):
        self.assertEqual(answers("www.wayra.test", "A"), ["10.212.6.10"])
        self.assertEqual(answers("www.wayra.test", "AAAA"), ["2001:db8:212:6::10"])
        self.assertEqual(answers("wayra.test", "NS"), ["ns1.wayra.test."])
        self.assertEqual(answers("wayra.test", "MX"), ["10", "correo.wayra.test."])
        self.assertIn("ejemplo", " ".join(answers("wayra.test", "TXT")))

    def test_cname(self):
        self.assertEqual(answers("blog.wayra.test"), ["www.wayra.test.", "10.212.6.10"])

    def test_nxdomain_carries_soa(self):
        out = dig("noexiste.wayra.test")
        self.assertIn("status: NXDOMAIN", out)
        self.assertRegex(out, r"wayra\.test\.\s+\d+\s+IN\s+SOA")

    def test_ttl_goes_down_and_then_the_resolver_asks_again(self):
        restart_resolver()
        ttl = lambda: int(LAB.out("cliente", "dig", "+noall", "+answer", "rapido.wayra.test").split()[1])
        first = ttl()
        asked = log_of("auth").count("rapido.wayra.test.")
        time.sleep(2)
        second = ttl()
        self.assertLess(second, first)
        self.assertEqual(log_of("auth").count("rapido.wayra.test."), asked)
        time.sleep(3.5)
        ttl()
        self.assertEqual(log_of("auth").count("rapido.wayra.test."), asked + 1)

    def test_resolver_without_recursion_only_tells_what_it_has(self):
        restart_resolver()
        out = dig(f"@{RESOLVER}", "+norecurse", "nunca-preguntado.wayra.test")
        self.assertIn("ANSWER: 0", out)
        self.assertNotIn("nunca-preguntado", log_of("auth"))


class C_Transport(unittest.TestCase):
    def test_large_answer_makes_dig_retry_over_tcp(self):
        out = dig(f"@{AUTH}", "grande.wayra.test", "TXT")
        self.assertIn("Truncated, retrying in TCP mode", out)
        self.assertIn("(TCP)", out)
        self.assertIn("ANSWER: 1", out.split("Truncated")[1])

    def test_large_answer_also_works_through_the_resolver(self):
        out = dig("grande.wayra.test", "TXT")
        self.assertIn("Truncated, retrying in TCP mode", out)
        self.assertIn("(TCP)", out)
        self.assertIn("status: NOERROR", out.split("Truncated")[1])
        self.assertRegex(log_of("auth"), r"tcp 10\.212\.2\.10:\d+ id=\d+ grande")

    def test_ignoring_truncation_shows_the_empty_answer(self):
        out = dig(f"@{AUTH}", "+ignore", "grande.wayra.test", "TXT")
        self.assertRegex(out, r"flags: qr aa tc")
        self.assertIn("ANSWER: 0", out)

    def test_capture_matches_dig(self):
        restart_resolver()
        capture("cliente", "dns", 6, "udp port 53")
        out = dig("blog.wayra.test")
        msg_id = int(re.search(r"id: (\d+)", out).group(1))
        time.sleep(0.5)
        LAB.sh("cliente", "pkill", "tcpdump")
        time.sleep(0.5)
        fields = tshark("cliente", "dns", "-T", "fields", "-E", "separator=|", "-e", "dns.id",
                        "-e", "dns.flags.response", "-e", "dns.qry.name", "-e", "dns.a",
                        "-e", "dns.cname")
        rows = [line.split("|") for line in fields.strip().splitlines()]
        queries = [row for row in rows if row[1] in ("0", "False")]
        replies = [row for row in rows if row[1] in ("1", "True")]
        self.assertEqual(len(queries), 1, fields)
        self.assertEqual(len(replies), 1, fields)
        self.assertEqual(int(queries[0][0], 16), msg_id)
        self.assertEqual(int(replies[0][0], 16), msg_id)
        self.assertEqual(replies[0][2:], ["blog.wayra.test", "10.212.6.10", "www.wayra.test"])

    def test_upstream_queries_change_id_and_port(self):
        restart_resolver()
        capture("resolver", "up", 8, f"udp and dst port 53 and dst host {AUTH}")
        answers("www.wayra.test")
        for index in range(6):
            answers(f"azar{index}.wayra.test")
        time.sleep(0.5)
        LAB.sh("resolver", "pkill", "tcpdump")
        time.sleep(0.5)
        fields = tshark("resolver", "up", "-T", "fields", "-E", "separator=|",
                        "-e", "dns.id", "-e", "udp.srcport")
        rows = [line.split("|") for line in fields.strip().splitlines()]
        self.assertEqual(len(rows), 7, fields)
        self.assertGreaterEqual(len({row[0] for row in rows}), 6)
        self.assertGreaterEqual(len({row[1] for row in rows}), 6)

    def test_dns_ask_reads_the_same_as_dig(self):
        out = LAB.out("cliente", "/w12/bin/dns_ask", RESOLVER, "www.wayra.test")
        self.assertIn("rd=1 ra=1 rcode=0", out)
        self.assertRegex(out, r"www\.wayra\.test\. ttl=\d+ A 10\.212\.6\.10")
        out = LAB.out("cliente", "/w12/bin/dns_ask", ROOT, "www.wayra.test", "--norecurse")
        self.assertIn("rd=0 ra=0 rcode=0", out)
        self.assertIn("respuestas=0 autoridad=1 adicionales=1", out)

    def test_dns_ask_reports_a_bad_address(self):
        done = LAB.sh("cliente", "/w12/bin/dns_ask", "no-es-ip", "www.wayra.test")
        self.assertEqual(done.returncode, 2)


def nc(request):
    return LAB.out("cliente", "sh", "-c", f"printf '{request}' | nc -w1 {WEB} 80")


class D_Http(unittest.TestCase):
    def test_by_hand_request(self):
        out = nc(r"GET / HTTP/1.1\r\nHost: www.wayra.test\r\n\r\n")
        self.assertTrue(out.startswith("HTTP/1.1 200 OK"), out)
        self.assertIn("wayra</html>", out)

    def test_host_chooses_the_site(self):
        self.assertIn("el blog", nc(r"GET / HTTP/1.1\r\nHost: blog.wayra.test\r\n\r\n"))

    def test_this_toy_server_accepts_a_missing_host(self):
        # RFC 9112 le exige 400 a un servidor de verdad. Los apuntes muestran la diferencia.
        self.assertTrue(nc(r"GET / HTTP/1.1\r\n\r\n").startswith("HTTP/1.1 200"))

    def test_status_404(self):
        self.assertTrue(nc(r"GET /nada HTTP/1.1\r\nHost: a\r\n\r\n").startswith("HTTP/1.1 404"))

    def test_head_has_headers_but_no_body(self):
        out = curl("-I", f"{URL}/")
        self.assertIn("Content-Length: 67", out)
        self.assertNotIn("<html>", out)

    def test_unknown_method_is_answered_501(self):
        self.assertIn(" 501 ", nc(r"DELETE / HTTP/1.1\r\nHost: a\r\n\r\n").splitlines()[0])

    def test_cookie_carries_the_state(self):
        self.assertIn("visita 1", curl(f"{URL}/visitas"))
        self.assertIn("visita 1", curl(f"{URL}/visitas"))  # sin cookie, no nos recuerda
        self.assertIn("visita 8", curl("-H", "Cookie: visitas=7", f"{URL}/visitas"))
        self.assertIn("visitas=8", curl("-D-", "-H", "Cookie: visitas=7", f"{URL}/visitas"))

    def test_conditional_request(self):
        first = curl("-D-", f"{URL}/")
        self.assertIn("200 OK", first)
        etag = re.search(r"ETag: (\S+)", first).group(1)
        again = curl("-D-", "-H", f"If-None-Match: {etag}", f"{URL}/")
        self.assertIn("304 Not Modified", again)
        self.assertNotIn("<html>", again)
        other = curl("-D-", "-H", 'If-None-Match: "otro"', f"{URL}/")
        self.assertIn("200 OK", other)

    def timing(self, *args):
        fmt = "%{time_connect} %{time_starttransfer} %{time_total} %{num_connects}\n"
        out = curl(*args, "-o", "/dev/null", "-w", fmt)
        return [[float(value) for value in line.split()] for line in out.strip().splitlines()]

    def test_connect_costs_one_rtt_and_first_byte_another(self):
        # El RTT hacia web es de 50 ms: conexión a 50 ms, primer byte a 100 ms.
        best = min(self.timing(f"{URL}/")[0] for _ in range(3))
        connect, first_byte, _total, connections = best
        self.assertEqual(connections, 1)
        self.assertGreaterEqual(connect, 0.045)
        self.assertGreaterEqual(first_byte, 0.090)
        self.assertLess(connect, first_byte)

    def test_persistent_connection_saves_handshakes(self):
        urls = [f"{URL}/{name}" for name in ("", "a.png", "b.png", "c.png")]
        outputs = [arg for _ in urls for arg in ("-o", "/dev/null")]
        persistent, separate = [], []
        for _ in range(3):
            out = curl(*outputs, "-w", "%{num_connects} %{time_total}\n", *urls)
            rows = [line.split() for line in out.strip().splitlines()]
            self.assertEqual([row[0] for row in rows], ["1", "0", "0", "0"], out)
            persistent.append(sum(float(row[1]) for row in rows))
            separate.append(sum(self.timing("-H", "Connection: close", url)[0][2] for url in urls))
        # 5 RTT contra 8 RTT. Se compara lo mejor de tres: una máquina compartida solo agrega tiempo.
        self.assertGreaterEqual(min(persistent), 0.22)
        self.assertGreaterEqual(min(separate), 0.36)
        self.assertGreater(min(separate), min(persistent) * 1.2)

    def test_connection_close_is_honored(self):
        out = curl("-D-", "-o", "/dev/null", "-H", "Connection: close", f"{URL}/")
        self.assertIn("Connection: close", out)


def through_proxy(path):
    out = curl("-D-", "-x", PROXY, f"{URL}{path}")
    state = re.search(r"X-Cache: (\w+)", out)
    return (state.group(1) if state else None), out


def origin_requests():
    return int(LAB.out("cliente", "curl", "-s", f"{URL}/contadores").split()[0])


class E_Proxy(unittest.TestCase):
    def test_fresh_stale_revalidated_changed(self):
        self.assertEqual(through_proxy("/noticia")[0], "MISS")
        before = origin_requests()
        state, out = through_proxy("/noticia")
        self.assertEqual(state, "HIT")
        self.assertIn("Age:", out)
        # La consulta del contador cuenta una petición; el HIT no agrega ninguna.
        self.assertEqual(origin_requests(), before + 1)
        time.sleep(3.2)
        self.assertEqual(through_proxy("/noticia")[0], "REVALIDATED")
        curl(f"{URL}/cambiar")
        self.assertEqual(through_proxy("/noticia")[0], "HIT")  # sigue fresca: dato viejo
        time.sleep(3.2)
        state, out = through_proxy("/noticia")
        self.assertEqual(state, "MISS")
        self.assertRegex(out, r"versión \d+")

    def test_no_store_never_hits(self):
        self.assertEqual(through_proxy("/privado")[0], "BYPASS")
        self.assertEqual(through_proxy("/privado")[0], "BYPASS")

    def test_without_indications_it_is_not_stored(self):
        self.assertEqual(through_proxy("/hora")[0], "BYPASS")

    def test_hit_is_faster_than_going_to_the_origin(self):
        through_proxy("/a.png")
        fmt = "%{time_total}\n"
        hit = min(float(curl("-o", "/dev/null", "-w", fmt, "-x", PROXY, f"{URL}/a.png"))
                  for _ in range(3))
        direct = min(float(curl("-o", "/dev/null", "-w", fmt, f"{URL}/a.png")) for _ in range(3))
        self.assertLess(hit, direct / 2)


def streams_overlap(log):
    """¿Salió un DATA del stream 3 antes del último DATA del stream 1?"""
    data = re.findall(r"-> DATA stream=(\d+)", log)
    if sorted(set(data)) != ["1", "3"]:
        return False
    return data.index("3") < len(data) - 1 - data[::-1].index("1")


class F_Http2(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        LAB.daemon("web", f"cd /w12 && exec python3 -u h2_frames.py {WEB} 8080 >/tmp/h2.log 2>&1")
        if not wait_until(lambda: "h2_frames en" in LAB.out("web", "cat", "/tmp/h2.log"), 10):
            raise RuntimeError(LAB.out("web", "cat", "/tmp/h2.log"))
        capture("cliente", "h2", 15, "tcp port 8080")
        base = f"http://{WEB}:8080"
        # A veces curl abre el segundo stream recién cuando termina el primero, y entonces no hay
        # mezcla que ver: es decisión de curl, no del servidor. Se repite hasta ver dos abiertos.
        for _attempt in range(ATTEMPTS):
            seen = len(LAB.out("web", "cat", "/tmp/h2.log"))
            cls.curl_out = curl("--http2-prior-knowledge", "--parallel", "-o", "/dev/null", "-o", "/dev/null",
                                "-w", "%{http_version} %{http_code} %{num_connects} %{size_download}\n",
                                f"{base}/uno", f"{base}/dos")
            time.sleep(0.3)
            cls.log = LAB.out("web", "cat", "/tmp/h2.log")[seen:]
            if streams_overlap(cls.log):
                break
        time.sleep(0.5)
        LAB.sh("cliente", "pkill", "tcpdump")
        time.sleep(0.5)
        LAB.sh("web", "pkill", "-f", "h2_frames")

    def test_curl_used_http2_over_one_connection(self):
        rows = [line.split() for line in self.curl_out.strip().splitlines()]
        self.assertEqual([row[:2] for row in rows], [["2", "200"]] * 2, self.curl_out)
        self.assertEqual(sum(int(row[2]) for row in rows), 1, self.curl_out)
        self.assertEqual([row[3] for row in rows], ["2000", "2000"], self.curl_out)

    def test_streams_take_turns(self):
        self.assertTrue(streams_overlap(self.log), self.log)

    def test_second_headers_are_much_smaller(self):
        sizes = dict(re.findall(r"<- HEADERS stream=(\d+) largo=(\d+)", self.log))
        self.assertLess(int(sizes["3"]), int(sizes["1"]) / 2, self.log)

    def test_tshark_reads_the_same_frames(self):
        out = tshark("cliente", "h2", "-d", "tcp.port==8080,http2", "-Y", "http2",
                     "-T", "fields", "-e", "http2.headers.path")
        self.assertIn("/uno", out)
        self.assertIn("/dos", out)


class G_Delegation(unittest.TestCase):
    """El laboratorio: delegar killa.test. Se salta hasta que completen las dos zonas."""

    @classmethod
    def setUpClass(cls):
        folder = require_answers("test.zone", "killa.zone")
        LAB.sh("tld", "pkill", "-f", "dns_auth")
        wait_until(lambda: "dns_auth" not in LAB.out("tld", "ps"), 5)
        LAB.daemon("tld", f"cd /w12 && exec python3 -u dns_auth.py {TLD} {folder}/test.zone >/tmp/tld.log 2>&1")
        LAB.daemon("killa", f"cd /w12 && exec python3 -u dns_auth.py 10.212.8.10 {folder}/killa.zone "
                            ">/tmp/killa.log 2>&1")
        for node in ("tld", "killa"):
            if not wait_until(lambda: "autoritativo" in log_of(node), 10):
                raise RuntimeError(log_of(node))
        restart_resolver()

    def test_parent_delegates_with_glue(self):
        out = dig(f"@{TLD}", "+norecurse", "www.killa.test")
        self.assertIn("ANSWER: 0", out)
        self.assertRegex(out, r"killa\.test\.\s+\d+\s+IN\s+NS\s+ns1\.killa\.test\.")
        self.assertRegex(out, r"ns1\.killa\.test\.\s+\d+\s+IN\s+A\s+10\.212\.8\.10")

    def test_child_answers_with_authority(self):
        out = dig("@10.212.8.10", "+norecurse", "www.killa.test")
        self.assertRegex(out, r"flags: qr aa;")
        self.assertRegex(out, r"www\.killa\.test\.\s+120\s+IN\s+A\s+10\.212\.6\.10")

    def test_resolver_finds_every_record(self):
        self.assertEqual(answers("www.killa.test"), ["10.212.6.10"])
        self.assertEqual(answers("luna.killa.test"), ["www.killa.test.", "10.212.6.10"])
        self.assertEqual(answers("killa.test", "MX"), ["20", "correo.killa.test."])
        self.assertEqual(answers("correo.killa.test"), ["10.212.6.25"])
        self.assertEqual(answers("killa.test", "NS"), ["ns1.killa.test."])
        self.assertTrue(answers("killa.test", "TXT"))


if __name__ == "__main__":
    unittest.main()
test_wire.pyDescargar test_wire.py
"""Pruebas sin red: formato de los mensajes, autoritativo, resolver y reglas de cache."""
import os
import struct
import unittest

import dns_auth
import dns_resolver
import dns_wire as w
import mini_cache
from netlab import HERE

A, NS, MX, TXT = w.TYPES["A"], w.TYPES["NS"], w.TYPES["MX"], w.TYPES["TXT"]


def zone(name):
    return dns_auth.Zone(*dns_auth.load_zone(os.path.join(HERE, "red", "zones", name)))


def ask(server_zone, name, rtype=A, limit=None):
    query = w.encode(w.Message(7, 0, question=[(name, rtype)]))
    return w.decode(dns_auth.respond(server_zone, query, limit)[0])


class Format(unittest.TestCase):
    def test_query_bytes_follow_rfc1035(self):
        raw = w.encode(w.Message(0x1234, w.FLAG_RD, question=[("www.uni.test.", A)]))
        self.assertEqual(raw[:12], bytes.fromhex("1234 0100 0001 0000 0000 0000"))
        self.assertEqual(raw[12:], b"\x03www\x03uni\x04test\x00\x00\x01\x00\x01")

    def test_round_trip(self):
        records = [w.Record("a.test.", A, 60, "10.0.0.1"),
                   w.Record("a.test.", w.TYPES["AAAA"], 60, "2001:db8::1"),
                   w.Record("a.test.", w.TYPES["CNAME"], 60, "b.test."),
                   w.Record("a.test.", MX, 60, (10, "m.a.test.")),
                   w.Record("a.test.", TXT, 60, "hola " * 100)]
        back = w.decode(w.encode(w.Message(1, w.FLAG_QR, answer=records)))
        self.assertEqual([record.data for record in back.answer], [record.data for record in records])

    def test_compression_uses_a_pointer_to_the_repeated_suffix(self):
        message = w.Message(1, 0, question=[("www.wayra.test.", A)],
                            answer=[w.Record("www.wayra.test.", A, 60, "10.0.0.1")])
        raw = w.encode(message)
        self.assertEqual(raw[12 + 16 + 4:12 + 16 + 6], b"\xc0\x0c")
        self.assertEqual(w.decode(raw).answer[0].name, "www.wayra.test.")

    def test_rejects_pointer_loop(self):
        raw = struct.pack("!6H", 1, 0, 1, 0, 0, 0) + b"\xc0\x0c" + b"\0\1\0\1"
        with self.assertRaises(w.WireError):
            w.decode(raw)

    def test_rejects_cut_messages(self):
        raw = w.encode(w.Message(1, w.FLAG_QR, answer=[w.Record("a.test.", A, 60, "10.0.0.1")]))
        for size in range(len(raw)):
            with self.assertRaises(w.WireError):
                w.decode(raw[:size])


class Authoritative(unittest.TestCase):
    def test_root_refers_to_tld_with_glue_and_without_authority(self):
        reply = ask(zone("root.zone"), "www.wayra.test.")
        self.assertFalse(reply.flags & w.FLAG_AA)
        self.assertEqual(reply.answer, [])
        self.assertEqual(reply.authority[0].data, "ns1.nic.test.")
        self.assertEqual(reply.additional[0].data, "10.212.4.10")

    def test_final_answer_is_authoritative(self):
        reply = ask(zone("wayra.zone"), "www.wayra.test.")
        self.assertTrue(reply.flags & w.FLAG_AA)
        self.assertEqual(reply.answer[0].data, "10.212.6.10")

    def test_names_ignore_case(self):
        self.assertEqual(ask(zone("wayra.zone"), "WwW.WaYrA.TeSt.").answer[0].data, "10.212.6.10")

    def test_cname_is_followed_inside_the_zone(self):
        reply = ask(zone("wayra.zone"), "blog.wayra.test.")
        self.assertEqual([record.rtype for record in reply.answer], [w.TYPES["CNAME"], A])

    def test_nxdomain_and_nodata_differ(self):
        wayra = zone("wayra.zone")
        self.assertEqual(ask(wayra, "nada.wayra.test.").rcode, w.NXDOMAIN)
        nodata = ask(wayra, "www.wayra.test.", MX)
        self.assertEqual(nodata.rcode, w.NOERROR)
        self.assertEqual(nodata.answer, [])
        self.assertEqual(nodata.authority[0].rtype, w.TYPES["SOA"])

    def test_refuses_names_outside_the_zone(self):
        self.assertEqual(ask(zone("wayra.zone"), "www.otra.test.").rcode, w.REFUSED)

    def test_large_answer_is_truncated_only_on_udp(self):
        wayra = zone("wayra.zone")
        short = ask(wayra, "grande.wayra.test.", TXT, dns_auth.UDP_LIMIT)
        self.assertTrue(short.flags & w.FLAG_TC)
        self.assertEqual(short.answer, [])
        whole = ask(wayra, "grande.wayra.test.", TXT)
        self.assertFalse(whole.flags & w.FLAG_TC)
        self.assertGreater(len(whole.answer[0].data), 512)


class ReplyChecks(unittest.TestCase):
    QUESTION = ("www.wayra.test.", A)

    def reply(self, msg_id=99, flags=w.FLAG_QR, question=None):
        return w.Message(msg_id, flags, question=[question or self.QUESTION])

    def accepts(self, reply, server="10.0.0.1"):
        return dns_resolver.acceptable_reply(reply, 99, self.QUESTION, (server, 53), "10.0.0.1")

    def test_matching_reply_is_accepted(self):
        self.assertTrue(self.accepts(self.reply()))

    def test_wrong_id_is_rejected(self):
        self.assertFalse(self.accepts(self.reply(msg_id=98)))

    def test_wrong_question_is_rejected(self):
        self.assertFalse(self.accepts(self.reply(question=("banco.test.", A))))
        self.assertFalse(self.accepts(self.reply(question=("www.wayra.test.", MX))))

    def test_wrong_server_is_rejected(self):
        self.assertFalse(self.accepts(self.reply(), server="10.0.0.2"))

    def test_a_query_is_not_a_reply(self):
        self.assertFalse(self.accepts(self.reply(flags=0)))

    def test_glue_outside_the_delegated_zone_is_ignored(self):
        reply = w.Message(1, w.FLAG_QR, additional=[
            w.Record("ns1.wayra.test.", A, 60, "10.0.0.1"),
            w.Record("www.banco.test.", A, 60, "10.6.6.6")])
        self.assertEqual([r.name for r in dns_resolver.usable_glue(reply, "wayra.test.")],
                         ["ns1.wayra.test."])

    def test_bailiwick_does_not_match_a_mere_text_suffix(self):
        self.assertFalse(w.in_bailiwick("xwayra.test.", "wayra.test."))
        self.assertTrue(w.in_bailiwick("a.wayra.test.", "wayra.test."))


class CacheTtl(unittest.TestCase):
    def setUp(self):
        self.now = 1000.0
        self.cache = dns_resolver.Cache(clock=lambda: self.now)
        self.record = w.Record("a.test.", A, 10, "10.0.0.1")

    def test_ttl_counts_down_and_expires(self):
        self.cache.put("a.test.", A, [self.record])
        self.assertEqual(self.cache.get("a.test.", A)[0][0].ttl, 10)
        self.now += 4
        self.assertEqual(self.cache.get("a.test.", A)[0][0].ttl, 6)
        self.now += 6
        self.assertIsNone(self.cache.get("a.test.", A))

    def test_ttl_zero_is_not_stored(self):
        self.cache.put("a.test.", A, [w.Record("a.test.", A, 0, "10.0.0.1")])
        self.assertIsNone(self.cache.get("a.test.", A))

    def test_negative_answers_are_remembered(self):
        self.cache.put("x.test.", A, [], ttl=5, rcode=w.NXDOMAIN)
        self.assertEqual(self.cache.get("x.test.", A), ([], w.NXDOMAIN))


class HttpCacheRules(unittest.TestCase):
    def test_lifetime(self):
        self.assertEqual(mini_cache.freshness_lifetime("max-age=60"), 60)
        self.assertEqual(mini_cache.freshness_lifetime("public, max-age=5"), 5)
        self.assertEqual(mini_cache.freshness_lifetime("max-age=60, no-cache"), 0)
        self.assertEqual(mini_cache.freshness_lifetime("max-age=hola"), 0)
        self.assertEqual(mini_cache.freshness_lifetime(None), 0)

    def test_what_can_be_stored(self):
        self.assertTrue(mini_cache.storable(200, "max-age=60", None))
        self.assertTrue(mini_cache.storable(200, "no-cache", '"x"'))
        self.assertFalse(mini_cache.storable(200, "no-store, max-age=60", '"x"'))
        self.assertFalse(mini_cache.storable(200, "private, max-age=60", None))
        self.assertFalse(mini_cache.storable(404, "max-age=60", None))
        self.assertFalse(mini_cache.storable(200, None, None))


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

Laboratorio

Archivos de partida del laboratorio de la semana.

lab/killa.zoneDescargar lab/killa.zone
; La zona nueva. Formato: nombre ttl tipo datos, con nombres completos.
killa.test. 3600 SOA ns1.killa.test. admin.killa.test. 2026110201 3600 600 86400 60
; TODO: agreguen estos registros:
;   - el NS de la zona, ns1.killa.test., y su A (10.212.8.10)
;   - www.killa.test.: A 10.212.6.10, con TTL de 120 segundos
;   - luna.killa.test.: un alias de www.killa.test.
;   - correo de la zona: MX de preferencia 20 hacia correo.killa.test., con A 10.212.6.25
;   - un TXT en killa.test. con el texto que quieran
lab/test.zoneDescargar lab/test.zone
; El TLD. No conoce los nombres de abajo, solo a quién preguntarle por cada dominio.
test. 3600 SOA ns1.nic.test. admin.nic.test. 2026110201 3600 600 86400 300
test. 3600 NS ns1.nic.test.
ns1.nic.test. 3600 A 10.212.4.10
a.raiz.test. 86400 A 10.212.3.10
; Delegación de wayra.test. con su glue
wayra.test. 3600 NS ns1.wayra.test.
ns1.wayra.test. 3600 A 10.212.5.10
; TODO: deleguen killa.test. en el servidor ns1.killa.test., que está en 10.212.8.10.
; Hacen falta dos registros: el NS y su glue. ¿Por qué aquí el glue es indispensable?

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 12: DNS y HTTP. Se usa dentro del contenedor del curso.
#
#   make           compila dns_ask (estático, para correrlo dentro de los nodos) y revisa los
#                  programas de Python
#   make test      corre las pruebas (test_*.py), que levantan la red y siempre la destruyen.
#                  Las pruebas de la delegación usan lab/ y se saltan hasta que la completen
#   make up        levanta la red y arranca los servidores (red/red.clab.yml)
#   make down      destruye la red y su red de Docker
#   make clean     lo anterior, y borra lo generado

CC = gcc
CFLAGS = -Wall -Wextra -std=c17 -D_DEFAULT_SOURCE -O2 -g
LDFLAGS = -static
PY = dns_wire.py dns_auth.py dns_resolver.py mini_web.py mini_cache.py h2_frames.py

.PHONY: all test up down clean

all: bin/dns_ask
	@PYTHONDONTWRITEBYTECODE=1 python3 -c 'import ast, sys; [ast.parse(open(f, encoding="utf-8").read(), f) for f in sys.argv[1:]]' $(PY)

bin/dns_ask: dns_ask.c
	@mkdir -p bin
	$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)

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

up: all
	cd red && containerlab deploy -t red.clab.yml --reconfigure
	sh red/start.sh

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

clean: down
	rm -rf bin __pycache__ red/clab-w12-red