Repository navigation
Expand file tree
/
Copy pathred_dns.py
More file actions
295 lines (230 loc) · 11.6 KB
/
Copy pathred_dns.py
File metadata and controls
295 lines (230 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
"""Red y DNS de davidameth.dev: lo que un navegador, un correo o una autoridad
de certificados ven del dominio, comprobado desde fuera.
python checks/red_dns.py > resumen.json
Lo corre todos los días `.github/workflows/red-dns.yml` y el resultado se
publica en qa.davidameth.dev como cualquier otra suite. Solo usa la biblioteca
estándar: el DNS se consulta por DNS sobre HTTPS a dos resolutores públicos
(Google y Cloudflare), así que no depende del resolutor de la máquina.
Cada comprobación es una «prueba» con su archivo (el grupo), su título y su
resultado, en el mismo formato que las demás suites.
"""
from __future__ import annotations
import http.client
import json
import os
import socket
import ssl
import sys
import time
import urllib.parse
import urllib.request
from collections.abc import Callable
from datetime import UTC, datetime
DOMINIO = "davidameth.dev"
# Los subdominios, todos en Vercel con un CNAME propio.
SUBDOMINIOS = ["www", "torneo", "api", "qa"]
HOSTS = [DOMINIO, *(f"{s}.{DOMINIO}" for s in SUBDOMINIOS)]
DIAS_MINIMOS = 20 # Vercel renueva con unos 30 días de margen
UN_ANIO = 365 * 24 * 3600
RESOLUTORES = {
"Google": "https://dns.google/resolve",
"Cloudflare": "https://cloudflare-dns.com/dns-query",
}
class Fallo(Exception):
"""La comprobación no se cumple; el mensaje dice por qué."""
def doh(nombre: str, tipo: str, resolutor: str = "Google") -> dict:
q = urllib.parse.urlencode({"name": nombre, "type": tipo, "do": "1"})
pet = urllib.request.Request(
f"{RESOLUTORES[resolutor]}?{q}", headers={"accept": "application/dns-json"}
)
with urllib.request.urlopen(pet, timeout=10) as r:
return json.load(r)
def respuestas(nombre: str, tipo: str) -> list[str]:
codigo = {"A": 1, "CNAME": 5, "MX": 15, "TXT": 16, "AAAA": 28, "DS": 43, "CAA": 257}[tipo]
datos = doh(nombre, tipo)
# Tal cual llega: un CAA es `0 issue "letsencrypt.org"` y sus comillas son
# parte del valor. Solo a los TXT se les quitan (en `txt`).
return [a["data"] for a in datos.get("Answer", []) if a["type"] == codigo]
def txt(nombre: str) -> list[str]:
# Un TXT largo llega partido en cadenas "a" "b"; se unen.
return [v.strip('"').replace('" "', "") for v in respuestas(nombre, "TXT")]
# ------------------------------------------------------------ comprobaciones
def dnssec_ds() -> None:
if not respuestas(DOMINIO, "DS"):
raise Fallo("la zona padre (.dev) no publica un registro DS: DNSSEC está apagado")
def dnssec_valida(resolutor: str) -> Callable[[], None]:
def comprobar() -> None:
datos = doh(DOMINIO, "A", resolutor)
if datos.get("Status") != 0:
raise Fallo(f"{resolutor} respondió con código {datos.get('Status')} (2 = la firma no valida)")
if not datos.get("AD"):
raise Fallo(f"{resolutor} resolvió pero no marcó la respuesta como validada (AD)")
return comprobar
def resuelve(host: str) -> Callable[[], None]:
def comprobar() -> None:
if host == DOMINIO:
if not respuestas(host, "A"):
raise Fallo("el dominio principal no tiene registro A")
return
destino = respuestas(host, "CNAME")
if not destino or "vercel-dns" not in destino[0]:
raise Fallo(f"se esperaba un CNAME a Vercel y llegó {destino or 'nada'}")
return comprobar
def sin_aaaa() -> None:
# Vercel no admite IPv6 en dominios propios: un AAAA mandaría parte del
# tráfico a otro sitio y puede frenar la emisión del certificado.
if respuestas(DOMINIO, "AAAA"):
raise Fallo("hay un registro AAAA en el dominio principal; Vercel no atiende IPv6")
def spf_unico() -> None:
spf = [t for t in txt(DOMINIO) if t.startswith("v=spf1")]
if len(spf) != 1:
raise Fallo(f"tiene que haber exactamente un SPF y hay {len(spf)} (con dos, ambos se ignoran)")
def spf_cierra() -> None:
spf = next((t for t in txt(DOMINIO) if t.startswith("v=spf1")), "")
if not spf.endswith(("~all", "-all")):
raise Fallo(f"el SPF no termina en ~all ni -all: «{spf}»")
def dmarc_publicado() -> None:
if not any(t.startswith("v=DMARC1") for t in txt(f"_dmarc.{DOMINIO}")):
raise Fallo("no hay registro DMARC en _dmarc.davidameth.dev")
def dmarc_informa() -> None:
dmarc = next((t for t in txt(f"_dmarc.{DOMINIO}") if t.startswith("v=DMARC1")), "")
if "rua=mailto:" not in dmarc:
raise Fallo("el DMARC no pide informes (rua): no se enteraría nadie de una suplantación")
def mx_reenvio() -> None:
mx = respuestas(DOMINIO, "MX")
if not mx or not all("efwd.spaceship.net" in m for m in mx):
raise Fallo(f"el correo ya no apunta al reenvío de Spaceship: {mx or 'sin MX'}")
def caa_letsencrypt() -> None:
caa = respuestas(DOMINIO, "CAA")
if not any('issue "letsencrypt.org"' in c for c in caa):
raise Fallo(f"ningún CAA autoriza a Let's Encrypt, la que usa Vercel: {caa or 'sin CAA'}")
def emisores(caa: list[str]) -> set[str]:
"""Las autoridades que autoriza un conjunto de CAA (`0 issue "x"` → x)."""
return {c.split('"')[1] for c in caa if ' issue "' in c and c.split('"')[1]}
def caa_igual_a_vercel() -> None:
# Vercel publica en el destino de sus CNAME las autoridades con las que
# puede emitir, Let's Encrypt y sus respaldos. El CAA del dominio principal
# tiene que autorizar exactamente esas: una de menos rompe una renovación
# de respaldo, y una de más (o mal escrita: el 2026-10-02 entró
# «section.com» por «sectigo.com») abre la puerta a quien no hace falta.
destino = respuestas(f"api.{DOMINIO}", "CNAME")
if not destino:
raise Fallo("no se pudo leer el destino de Vercel para comparar")
de_vercel = emisores(respuestas(destino[0].rstrip("."), "CAA"))
propios = emisores(respuestas(DOMINIO, "CAA"))
if propios != de_vercel:
faltan, sobran = sorted(de_vercel - propios), sorted(propios - de_vercel)
raise Fallo(f"el CAA no coincide con el de Vercel; faltan {faltan or 'ninguna'}, sobran {sobran or 'ninguna'}")
def caa_sin_comodines() -> None:
caa = respuestas(DOMINIO, "CAA")
if not any('issuewild ";"' in c for c in caa):
raise Fallo("el CAA no prohíbe certificados comodín (issuewild \";\")")
def certificado(host: str) -> Callable[[], None]:
def comprobar() -> None:
contexto = ssl.create_default_context()
try:
with socket.create_connection((host, 443), timeout=10) as s:
with contexto.wrap_socket(s, server_hostname=host) as t:
cert = t.getpeercert()
except ssl.SSLCertVerificationError as e:
raise Fallo(f"el certificado no es válido para {host}: {e.verify_message}") from e
vence = datetime.fromtimestamp(ssl.cert_time_to_seconds(cert["notAfter"]), UTC)
dias = (vence - datetime.now(UTC)).days
if dias < DIAS_MINIMOS:
raise Fallo(f"al certificado le quedan {dias} días (vence el {vence:%Y-%m-%d})")
return comprobar
def peticion(host: str, ruta: str = "/", seguro: bool = True) -> http.client.HTTPResponse:
clase = http.client.HTTPSConnection if seguro else http.client.HTTPConnection
conexion = clase(host, timeout=10)
conexion.request("HEAD", ruta, headers={"User-Agent": "qa.davidameth.dev red-dns"})
return conexion.getresponse()
def http_a_https(host: str) -> Callable[[], None]:
def comprobar() -> None:
r = peticion(host, seguro=False)
destino = r.getheader("Location", "")
if r.status not in (301, 308) or not destino.startswith("https://"):
raise Fallo(f"http:// respondió {r.status} hacia «{destino}», no una redirección permanente a https")
return comprobar
def hsts(host: str) -> Callable[[], None]:
def comprobar() -> None:
valor = peticion(host).getheader("Strict-Transport-Security", "")
edad = next((int(p.split("=")[1]) for p in valor.split(";") if p.strip().startswith("max-age")), 0)
if edad < UN_ANIO:
raise Fallo(f"HSTS «{valor or 'ausente'}»: se espera max-age de al menos un año")
return comprobar
def redirige(host: str, destino: str) -> Callable[[], None]:
def comprobar() -> None:
r = peticion(host)
llega = r.getheader("Location", "")
if r.status not in (301, 308) or not llega.startswith(destino):
raise Fallo(f"respondió {r.status} hacia «{llega}» y se esperaba {destino}")
return comprobar
PRUEBAS: list[tuple[str, str, Callable[[], None]]] = [
("dns", "DNSSEC: la zona padre publica el registro DS", dnssec_ds),
("dns", "DNSSEC: Google valida la firma", dnssec_valida("Google")),
("dns", "DNSSEC: Cloudflare valida la firma", dnssec_valida("Cloudflare")),
*(("dns", f"{h} resuelve hacia Vercel", resuelve(h)) for h in HOSTS),
("dns", "Sin AAAA en el dominio principal (Vercel no admite IPv6)", sin_aaaa),
("correo", "Un solo registro SPF", spf_unico),
("correo", "El SPF termina en ~all o -all", spf_cierra),
("correo", "DMARC publicado", dmarc_publicado),
("correo", "DMARC pide informes (rua)", dmarc_informa),
("correo", "MX apunta al reenvío de Spaceship", mx_reenvio),
("certificados", "CAA autoriza a Let's Encrypt", caa_letsencrypt),
("certificados", "CAA autoriza exactamente a las autoridades de Vercel", caa_igual_a_vercel),
("certificados", "CAA prohíbe certificados comodín", caa_sin_comodines),
*(("certificados", f"{h}: certificado válido y con más de {DIAS_MINIMOS} días", certificado(h)) for h in HOSTS),
*(("http", f"http://{h} redirige a https", http_a_https(h)) for h in HOSTS),
*(("http", f"{h}: HSTS de al menos un año", hsts(h)) for h in HOSTS),
("http", "www redirige al dominio principal", redirige(f"www.{DOMINIO}", f"https://{DOMINIO}")),
(
"http",
"El .vercel.app viejo del torneo redirige a torneo.davidameth.dev",
redirige("torneo-volleyball-2026.vercel.app", "https://torneo.davidameth.dev"),
),
]
def main() -> None:
inicio = time.monotonic()
pruebas = []
for archivo, titulo, comprobar in PRUEBAS:
t0 = time.monotonic()
error = None
try:
comprobar()
except Fallo as e:
error = str(e)
except Exception as e: # red caída, DNS que no responde: también es un fallo
error = f"{type(e).__name__}: {e}"
pruebas.append(
{
"archivo": archivo,
"titulo": titulo,
"proyecto": None,
"estado": "falla" if error else "pasa",
"ms": round((time.monotonic() - t0) * 1000),
"error": error[:300] if error else None,
}
)
fallidas = [p for p in pruebas if p["estado"] == "falla"]
servidor = os.environ.get("GITHUB_SERVER_URL", "https://github.com")
repo, corrida = os.environ.get("GITHUB_REPOSITORY"), os.environ.get("GITHUB_RUN_ID")
resumen = {
"suite": "red-dns",
"fecha": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
"resultado": "falla" if fallidas else "pasa",
"pasadas": len(pruebas) - len(fallidas),
"fallidas": len(fallidas),
"inestables": 0,
"omitidas": 0,
"duracion_s": round(time.monotonic() - inicio),
"cobertura": None,
"commit": os.environ.get("GITHUB_SHA", "")[:7] or None,
"corrida": f"{servidor}/{repo}/actions/runs/{corrida}" if repo and corrida else None,
"fallos": [f"{p['archivo']} › {p['titulo']}" for p in fallidas][:25],
"detalle": True,
"pruebas": pruebas,
}
json.dump(resumen, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
if __name__ == "__main__":
main()