#!/usr/bin/env python3 """ kb-audit-dns — резолвит все домены из NPM API через публичный DNS (8.8.8.8), сравнивает с локальным резолвом, детектит NXDOMAIN и расхождения. Пишет audit/YYYY-MM-DD-dns-drift.md. """ import json import ssl import subprocess import sys import urllib.request from datetime import date from pathlib import Path NPM_URL = "https://npm.dttb.ru" NPM_USER = "it5870@yandex.ru" NPM_PASS = "1qaz!QAZ" VAULT = Path(__file__).resolve().parent.parent OUT_DIR = VAULT / "audit" _CTX = ssl.create_default_context() _CTX.check_hostname = False _CTX.verify_mode = ssl.CERT_NONE def fetch_npm_domains(): """Берём домены из NPM API (вместо парсинга markdown — свежее).""" req = urllib.request.Request( f"{NPM_URL}/api/tokens", data=json.dumps({"identity": NPM_USER, "secret": NPM_PASS}).encode(), headers={"Content-Type": "application/json"}, method="POST", ) token = json.loads(urllib.request.urlopen(req, context=_CTX, timeout=10).read())["token"] req2 = urllib.request.Request( f"{NPM_URL}/api/nginx/proxy-hosts", headers={"Authorization": f"Bearer {token}"}, ) hosts = json.loads(urllib.request.urlopen(req2, context=_CTX, timeout=10).read()) domains = set() for h in hosts: for d in h.get("domain_names", []): domains.add(d) return sorted(domains) def dig(domain: str, server: str = "8.8.8.8") -> list[str]: """dig +short @server domain → список IP (пусто если NXDOMAIN/недоступен).""" try: r = subprocess.run( ["dig", f"@{server}", "+short", "+time=3", "+tries=1", domain, "A"], capture_output=True, text=True, timeout=8, ) return [l.strip() for l in r.stdout.splitlines() if l.strip() and not l.startswith(";")] except (subprocess.TimeoutExpired, FileNotFoundError): return [] def main(): today = date.today().isoformat() OUT_DIR.mkdir(parents=True, exist_ok=True) out = OUT_DIR / f"{today}-dns-drift.md" try: domains = fetch_npm_domains() except Exception as e: print(f"NPM fetch fail: {e}", file=sys.stderr) sys.exit(1) if len(domains) < 3: print(f"safety abort: domains {len(domains)} < 3", file=sys.stderr) sys.exit(2) results = [] for d in domains: public = dig(d, "8.8.8.8") local = dig(d, "10.0.0.1") # OpenWrt router results.append((d, public, local)) no_public = [r for r in results if not r[1]] split = [r for r in results if r[1] and r[2] and set(r[1]) != set(r[2])] no_local = [r for r in results if not r[2]] lines = [ "---", f"date: {today}", "type: audit", "source: kb-audit-dns.py", "tags: [audit, dns]", "---", "", f"# DNS resolve audit — {today}", "", f"Резолвим все домены из NPM через публичный DNS (8.8.8.8) и локальный роутер (10.0.0.1).", "", f"- Всего доменов: **{len(domains)}**", f"- NXDOMAIN на 8.8.8.8: {len(no_public)} / пустой ответ локально: {len(no_local)} / split-horizon: {len(split)}", "", ] if no_public: lines += ["## ❌ NXDOMAIN / не резолвится на 8.8.8.8 (публичный DNS)", ""] lines += ["| Домен | Локальный IP |", "|---|---|"] for d, _, loc in no_public: lines.append(f"| `{d}` | {','.join(loc) or '(тоже нет)'} |") lines += [""] if split: lines += ["## ⚠ Split-horizon — разные IP снаружи и внутри", "", "Это нормально для *.dttb.ru (внешний Let's Encrypt IP vs локальный 10.0.0.195). Но неожиданный split может быть багом.", ""] lines += ["| Домен | Публичный (8.8.8.8) | Локальный (10.0.0.1) |", "|---|---|---|"] for d, pub, loc in split: lines.append(f"| `{d}` | {','.join(pub)} | {','.join(loc)} |") lines += [""] if no_local: lines += ["## ⚠ Пустой локальный резолв (роутер не знает)", ""] for d, pub, _ in no_local: lines.append(f"- `{d}` (публичный: {','.join(pub) or '-'})") lines += [""] lines += ["## Полная таблица резолва", "", "| Домен | 8.8.8.8 | 10.0.0.1 |", "|---|---|---|"] for d, pub, loc in results: lines.append(f"| `{d}` | {','.join(pub) or '—'} | {','.join(loc) or '—'} |") lines += ["", "---", "*Автоматически через `scripts/kb-audit-dns.py`.*"] out.write_text("\n".join(lines)) print(f"dns drift: {out}") print(f" no_public: {len(no_public)} / split: {len(split)} / no_local: {len(no_local)}") if __name__ == "__main__": main()