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,ETagy peticiones condicionales. Proxies y código304. - 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/.
- Con
dig +trace, seguir la resolución de un nombre.pe. Condig @servidor +norecurse, repetir el recorrido a mano. - 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).
- Capturar una consulta de DNS y comparar el ID, las preguntas y las respuestas con lo que muestra
dig. - Escribir a mano una petición HTTP/1.1 con
nchacia un servidor local. Probar qué pasa si faltaHost. - Con
curl -v, ver una respuesta200y luego una304usandoIf-None-Match. - Medir con
curl -wel tiempo de resolución, de conexión y de primera respuesta. Repetir con una conexión reutilizada. - Avanzar con P4.
Algunas actividades se basan en los laboratorios de DNS y HTTP de Kurose y Ross.
Preguntas de repaso
- Un resolver sin nada en caché busca
www.uni.edu.pe. ¿A qué servidores pregunta y en qué orden? ¿Y la segunda vez? - ¿Qué ventajas y qué riesgos tiene un TTL muy corto?
- ¿Cuántos RTT tarda bajar una página con 3 imágenes con conexiones no persistentes y seriales? ¿Y con una conexión persistente?
- ¿Qué devuelve un servidor si el
ETagno cambió? ¿Qué se ahorra? - ¿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: 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;
}#!/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()#!/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()"""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)#!/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()#!/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()#!/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()"""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()# 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}#!/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; 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; 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; 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.
"""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()"""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.
; 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; 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.
# 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