Fichero de zona DNS: comprobaciones y resolución de nombres
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
- Líneas:
$ORIGIN dominio.(fija el origen),$TTL n(se ignora), registrosnombre [ttl] [IN] TIPO datosy consultasCONSULTA 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 nombreyTXT. - 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: errorlínea N: «x» no es una dirección IPv4. Una línea que no encaja:línea N: no se entiende «…». - 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). - Salida:
Zona origen (serie S, N registros)(serie?si no hay exactamente un SOA),Errores: ningunooErrores:con cada uno como- …(primero los de líneas, después SOA, NS, CNAME y los de NS sin A), y cada aviso comoAviso: …. - Cada consulta escribe
? nombre TIPOy la respuesta con dos espacios delante: si el nombre tiene un CNAME (y no se pregunta por el CNAME),nombre CNAME destinoy 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 comonombre TIPO valor(los MX por preferencia,10 mail.ejemplo.com.); si no hay,nombre: sin registros TIPO (el nombre existe)onombre: no existe (NXDOMAIN). Un CNAME que vuelve a un nombre ya visitado escribebucle 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.
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.
Ejemplo
$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
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)
Solución explicada
Ver la solución completa
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.