Apuntes DAM
Volver al inicio

Fichero de zona DNS: comprobaciones y resolución de nombres

Ejercicio de PythonMuy difícilUnos 90 minutos

Lee un fichero de zona como los de BIND, lo revisa (un único SOA, servidores de nombres con su registro A, CNAME que no conviven con otros registros, MX que no apuntan a un CNAME) y resuelve consultas siguiendo las cadenas de CNAME y ordenando los MX por preferencia.

  • Ficheros de zona de BIND
  • Nombres relativos y absolutos ($ORIGIN, @)
  • SOA, NS, A, AAAA, CNAME, MX, TXT
  • Registros de pegamento (glue)
  • Cadenas de CNAME
  • NXDOMAIN y respuesta vacía

Enunciado

Un servidor DNS autoritativo como BIND guarda cada dominio en un fichero de zona: un registro por línea con el nombre, el tipo y los datos. Los nombres que no terminan en punto son relativos al origen de la zona (www es www.ejemplo.com.), y @ es el propio origen.

Un error en la zona deja una web o el correo sin funcionar, así que named-checkzone revisa reglas como estas: la zona tiene un único SOA, sus servidores de nombres tienen dirección, un nombre con CNAME no puede tener ningún otro registro, y los MX y NS no deben apuntar a un alias.

Qué tiene que hacer el programa

  1. Líneas: $ORIGIN dominio. (fija el origen), $TTL n (se ignora), registros nombre [ttl] [IN] TIPO datos y consultas CONSULTA nombre TIPO. Lo que va detrás de ; es un comentario. Tipos: SOA (servidor, correo, serie… : se guarda la serie, el tercer dato), NS, A, AAAA, CNAME, MX preferencia nombre y TXT.
  2. Los nombres se pasan a absolutos: @ es el origen, un nombre acabado en punto se queda igual y cualquier otro se completa con .origen. Una A que no es IPv4 o una AAAA que no es IPv6: error línea N: «x» no es una dirección IPv4. Una línea que no encaja: línea N: no se entiende «…».
  3. Comprobaciones: exactamente un SOA (la zona debe tener exactamente un SOA y tiene N); algún NS en el origen (no hay registros NS para origen); un nombre con CNAME y más registros (nombre tiene un CNAME y otros registros (A, MX)); un NS o MX que apunta a un CNAME (aviso: el MX apunta a x, que es un CNAME (debe apuntar a un nombre con A o AAAA)); un NS dentro de la zona sin A ni AAAA (error: falta el registro A de x, que es servidor de nombres de la zona).
  4. Salida: Zona origen (serie S, N registros) (serie ? si no hay exactamente un SOA), Errores: ninguno o Errores: con cada uno como - … (primero los de líneas, después SOA, NS, CNAME y los de NS sin A), y cada aviso como Aviso: ….
  5. Cada consulta escribe ? nombre TIPO y la respuesta con dos espacios delante: si el nombre tiene un CNAME (y no se pregunta por el CNAME), nombre CNAME destino y se sigue con el destino; si el nombre está fuera de la zona, nombre está fuera de la zona: lo resuelve otro servidor; los registros encontrados como nombre TIPO valor (los MX por preferencia, 10 mail.ejemplo.com.); si no hay, nombre: sin registros TIPO (el nombre existe) o nombre: no existe (NXDOMAIN). Un CNAME que vuelve a un nombre ya visitado escribe bucle de CNAME.

Entrada

Un fichero de zona (con $ORIGIN) seguido de líneas CONSULTA nombre TIPO.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

La zona de una empresa

Entrada

$ORIGIN ejemplo.com.
$TTL 3600
@       IN SOA ns1.ejemplo.com. admin.ejemplo.com. 2026100601 7200 3600 1209600 3600
@       IN NS  ns1
@       IN NS  ns2.proveedor.net.
ns1     IN A   192.0.2.10
@       IN A   192.0.2.20
www     IN CNAME @
mail    IN A   192.0.2.30
@       IN MX  20 backup.proveedor.net.
@       IN MX  10 mail
ftp     300 IN CNAME www
blog    IN CNAME tienda.otra.net.
@       IN TXT "v=spf1 mx -all"
CONSULTA www.ejemplo.com. A
CONSULTA ejemplo.com. MX
CONSULTA ftp A
CONSULTA blog A
CONSULTA nada A
CONSULTA mail AAAA

Salida por consola

Zona ejemplo.com. (serie 2026100601, 12 registros)
Errores: ninguno
? www.ejemplo.com. A
  www.ejemplo.com. CNAME ejemplo.com.
  ejemplo.com. A 192.0.2.20
? ejemplo.com. MX
  ejemplo.com. MX 10 mail.ejemplo.com.
  ejemplo.com. MX 20 backup.proveedor.net.
? ftp.ejemplo.com. A
  ftp.ejemplo.com. CNAME www.ejemplo.com.
  www.ejemplo.com. CNAME ejemplo.com.
  ejemplo.com. A 192.0.2.20
? blog.ejemplo.com. A
  blog.ejemplo.com. CNAME tienda.otra.net.
  tienda.otra.net. está fuera de la zona: lo resuelve otro servidor
? nada.ejemplo.com. A
  nada.ejemplo.com.: no existe (NXDOMAIN)
? mail.ejemplo.com. AAAA
  mail.ejemplo.com.: sin registros AAAA (el nombre existe)

Una zona con errores

Entrada

$ORIGIN tienda.es.
@ IN SOA ns.tienda.es. hostmaster.tienda.es. 1 2 3 4 5
@ IN SOA ns.tienda.es. hostmaster.tienda.es. 2 2 3 4 5
@ IN NS ns
web IN A 10.0.0.300
web IN CNAME servidor
web IN A 10.0.0.3
servidor IN A 10.0.0.4
correo IN CNAME servidor
@ IN MX 10 correo
v6 IN AAAA 2001:db8::1
raro IN HINFO x86 Linux
CONSULTA web A

Salida por consola

Zona tienda.es. (serie ?, 9 registros)
Errores:
  - línea 5: «10.0.0.300» no es una dirección IPv4
  - línea 12: no se entiende «raro IN HINFO x86 Linux»
  - la zona debe tener exactamente un SOA y tiene 2
  - web.tienda.es. tiene un CNAME y otros registros (A)
  - falta el registro A de ns.tienda.es., que es servidor de nombres de la zona
Aviso: el MX apunta a correo.tienda.es., que es un CNAME (debe apuntar a un nombre con A o AAAA)
? web.tienda.es. A
  web.tienda.es. CNAME servidor.tienda.es.
  servidor.tienda.es. A 10.0.0.4

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Nombres absolutos

Escribe primero la función que completa los nombres: con ella todo lo demás se compara sin ambigüedad.

python
def absoluto(nombre):
    if nombre == "@":
        return origen
    return nombre if nombre.endswith(".") else f"{nombre}.{origen}"
2. Leer cada registro

Quita el comentario, separa por espacios y salta el TTL y la clase IN si están. Valida las direcciones con ipaddress.ip_address, que lanza ValueError si no es una IP.

3. Las comprobaciones

Con una función de(nombre, tipo) que devuelva los registros de un nombre, cada regla es una o dos líneas: contar SOA, buscar NS en el origen, mirar qué tipos tiene cada nombre.

4. Resolver

Un bucle: mientras el nombre tenga un CNAME, escríbelo y sigue con el destino (guardando los visitados para detectar bucles). Al llegar a un nombre sin CNAME, busca los registros del tipo pedido.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🐍PythonFichero de zona DNS: comprobaciones y resolución de nombresMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
$ORIGIN ejemplo.com.
$TTL 3600
@       IN SOA ns1.ejemplo.com. admin.ejemplo.com. 2026100601 7200 3600 1209600 3600
@       IN NS  ns1
@       IN NS  ns2.proveedor.net.
ns1     IN A   192.0.2.10
@       IN A   192.0.2.20
www     IN CNAME @
mail    IN A   192.0.2.30
@       IN MX  20 backup.proveedor.net.
@       IN MX  10 mail
ftp     300 IN CNAME www
blog    IN CNAME tienda.otra.net.
@       IN TXT "v=spf1 mx -all"
CONSULTA www.ejemplo.com. A
CONSULTA ejemplo.com. MX
CONSULTA ftp A
CONSULTA blog A
CONSULTA nada A
CONSULTA mail AAAA
Salida esperada
Zona ejemplo.com. (serie 2026100601, 12 registros)
Errores: ninguno
? www.ejemplo.com. A
  www.ejemplo.com. CNAME ejemplo.com.
  ejemplo.com. A 192.0.2.20
? ejemplo.com. MX
  ejemplo.com. MX 10 mail.ejemplo.com.
  ejemplo.com. MX 20 backup.proveedor.net.
? ftp.ejemplo.com. A
  ftp.ejemplo.com. CNAME www.ejemplo.com.
  www.ejemplo.com. CNAME ejemplo.com.
  ejemplo.com. A 192.0.2.20
? blog.ejemplo.com. A
  blog.ejemplo.com. CNAME tienda.otra.net.
  tienda.otra.net. está fuera de la zona: lo resuelve otro servidor
? nada.ejemplo.com. A
  nada.ejemplo.com.: no existe (NXDOMAIN)
? mail.ejemplo.com. AAAA
  mail.ejemplo.com.: sin registros AAAA (el nombre existe)
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
python
1import ipaddress
2import sys
3
4
5def main():
6    origen = "."
7    registros = []                      # (nombre absoluto, tipo, datos)
8    consultas = []
9    errores = []
10
11    def absoluto(nombre):
12        if nombre == "@":
13            return origen
14        return nombre if nombre.endswith(".") else f"{nombre}.{origen}"
15
16    for n, linea in enumerate(sys.stdin.read().split("\n"), start=1):
17        linea = linea.split(";")[0].strip()
18        if not linea:
19            continue
20        p = linea.split()
21        if p[0] == "$ORIGIN" and len(p) == 2:
22            origen = p[1] if p[1].endswith(".") else p[1] + "."
23            continue
24        if p[0] == "$TTL":
25            continue
26        if p[0] == "CONSULTA" and len(p) == 3:
27            consultas.append((absoluto(p[1]), p[2].upper()))
28            continue
29        nombre, resto = p[0], p[1:]
30        if resto and resto[0].isdigit():
31            resto = resto[1:]                       # TTL propio del registro
32        if resto and resto[0].upper() == "IN":
33            resto = resto[1:]
34        if not resto:
35            errores.append(f"línea {n}: no se entiende «{linea}»")
36            continue
37        tipo, datos = resto[0].upper(), resto[1:]
38        if tipo in ("A", "AAAA") and len(datos) == 1:
39            try:
40                ip = ipaddress.ip_address(datos[0])
41                if (tipo == "A") != (ip.version == 4):
42                    raise ValueError
43            except ValueError:
44                errores.append(f"línea {n}: «{datos[0]}» no es una dirección {'IPv4' if tipo == 'A' else 'IPv6'}")
45                continue
46            registros.append((absoluto(nombre), tipo, datos[0]))
47        elif tipo in ("CNAME", "NS") and len(datos) == 1:
48            registros.append((absoluto(nombre), tipo, absoluto(datos[0])))
49        elif tipo == "MX" and len(datos) == 2 and datos[0].isdigit():
50            registros.append((absoluto(nombre), tipo, (int(datos[0]), absoluto(datos[1]))))
51        elif tipo == "SOA" and len(datos) >= 3:
52            registros.append((absoluto(nombre), tipo, datos[2]))
53        elif tipo == "TXT" and datos:
54            registros.append((absoluto(nombre), tipo, " ".join(datos)))
55        else:
56            errores.append(f"línea {n}: no se entiende «{linea}»")
57
58    def de(nombre, tipo=None):
59        return [r for r in registros if r[0] == nombre and (tipo is None or r[1] == tipo)]
60
61    def en_zona(nombre):
62        return nombre == origen or nombre.endswith("." + origen)
63
64    soa = [r for r in registros if r[1] == "SOA"]
65    if len(soa) != 1:
66        errores.append(f"la zona debe tener exactamente un SOA y tiene {len(soa)}")
67    if not de(origen, "NS"):
68        errores.append(f"no hay registros NS para {origen}")
69    avisos = []
70    for nombre in dict.fromkeys(r[0] for r in registros):
71        tipos = [r[1] for r in de(nombre)]
72        if "CNAME" in tipos and len(tipos) > 1:
73            errores.append(f"{nombre} tiene un CNAME y otros registros ({', '.join(t for t in tipos if t != 'CNAME')})")
74    for _, tipo, dato in [r for r in registros if r[1] in ("NS", "MX")]:
75        destino = dato[1] if tipo == "MX" else dato
76        if de(destino, "CNAME"):
77            avisos.append(f"el {tipo} apunta a {destino}, que es un CNAME (debe apuntar a un nombre con A o AAAA)")
78        elif tipo == "NS" and en_zona(destino) and not (de(destino, "A") or de(destino, "AAAA")):
79            errores.append(f"falta el registro A de {destino}, que es servidor de nombres de la zona")
80    print(f"Zona {origen} (serie {soa[0][2] if len(soa) == 1 else '?'}, {len(registros)} registro{'' if len(registros) == 1 else 's'})")
81    print("Errores:" if errores else "Errores: ninguno")
82    for e in errores:
83        print(f"  - {e}")
84    for a in avisos:
85        print(f"Aviso: {a}")
86    for nombre, tipo in consultas:
87        print(f"? {nombre} {tipo}")
88        actual = nombre
89        vistos = set()
90        while True:
91            if not en_zona(actual):
92                print(f"  {actual} está fuera de la zona: lo resuelve otro servidor")
93                break
94            cname = de(actual, "CNAME")
95            if cname and tipo != "CNAME":
96                if actual in vistos:
97                    print("  bucle de CNAME")
98                    break
99                vistos.add(actual)
100                print(f"  {actual} CNAME {cname[0][2]}")
101                actual = cname[0][2]
102                continue
103            respuesta = de(actual, tipo)
104            if tipo == "MX":
105                respuesta = sorted(respuesta, key=lambda r: (r[2][0], r[2][1]))
106            for r in respuesta:
107                valor = f"{r[2][0]} {r[2][1]}" if tipo == "MX" else r[2]
108                print(f"  {actual} {tipo} {valor}")
109            if not respuesta:
110                print(f"  {actual}: sin registros {tipo} (el nombre existe)" if de(actual) else f"  {actual}: no existe (NXDOMAIN)")
111            break
112
113
114main()

El punto final distingue un nombre absoluto de uno relativo: olvidarlo en mail.ejemplo.com dentro de la zona ejemplo.com. produce mail.ejemplo.com.ejemplo.com., uno de los errores más típicos en BIND.

Un CNAME dice «este nombre es otro»: por eso no puede convivir con otros registros, y por eso el origen de la zona (que siempre tiene SOA y NS) nunca puede ser un CNAME.

Si los servidores de nombres de la zona están dentro de ella (ns1.ejemplo.com.), su dirección tiene que estar en la zona (y en la zona padre como registro de pegamento): si no, para resolver ejemplo.com habría que preguntar a un servidor cuyo nombre solo se puede resolver preguntándole a él.

NXDOMAIN (el nombre no existe) y una respuesta vacía (el nombre existe pero no tiene registros de ese tipo) son respuestas distintas, y los resolvedores las tratan de forma distinta en la caché.

Para ir más allá

  • Añade la resolución inversa con una zona in-addr.arpa y registros PTR.
  • Comprueba que la serie del SOA sigue el formato AAAAMMDDnn.
  • Resuelve también los MX hasta sus direcciones A, como haría un servidor de correo.

Dónde se explica