المستوى المطلوب: متوسط — يفترض إنك تعرف Python أساسي، GitHub Actions، وعندك فكرة عامة عن DNS records (A, MX, NS, CNAME).
أتمتة DNS Drift Detection بـ Python + GitHub Actions
لو حد عدّل A record لدومين شركتك الساعة 3 الصبح وأشار الـ IP لسيرفر مش بتاعك، الموقع ممكن يفضل بيخدم نسخة مزوّرة 6 ساعات قبل ما حد يلاحظ. السكربت اللي هتشوفه هنا بيكشف أي تغيير في DNS records خلال 5 دقايق من حصوله، وبيتكلف صفر دولار شهرياً على GitHub Actions free tier.
المشكلة باختصار
DNS Drift معناه إن records الدومين بتاعك اتغيّرت بدون علم الفريق، ولا حد لاحظ. ده ممكن يحصل من 3 مصادر شائعة:
- زميل غيّر record في Cloudflare panel وما اتذكرش يبلّغ الفريق على Slack.
- سكربت Terraform قديم اشتغل بالغلط ورجّع الـ DNS لحالة قديمة (drift من الـ source of truth).
- هاكر دخل على حساب الـ DNS provider واستولى على نطاق فرعي عبر Subdomain Takeover (راجع مصدر OWASP في الأسفل).
التأثير الفعلي بيتراوح بين انقطاع جزئي للموقع، لحد سرقة كاملة لاتصالات الإيميل لو الـ MX records اتغيّرت. الكارثة الأكبر إن غياب الأتمتة هنا معناه الاكتشاف من شكوى عميل، مش من نظامك.
المفهوم الأساسي: ليه DNS Drift صعب يتلاحظ بدون أتمتة
تعالى نشرحه بمثال بسيط الأول. تخيّل إن DNS زي دفتر تليفونات في عمارة. لو الواحد بيسأل البواب "شقة 7B رقمها كام؟"، البواب بيقول 5550100 ويفضل القاطنين في العمارة عارفين الرقم. لو فيه حد دخل وعدّل الدفتر لـ 5559999، الناس اللي عندهم الرقم القديم محفوظ في موبايلهم هيفضلوا يتصلوا بالقديم لحد ما يعدّلوا. الناس الجداد بس هيشوفوا الجديد. ده بالظبط اللي بيحصل في DNS resolver caching.
دلوقتي بشكل علمي ودقيق: لما طلب يوصل لدومين زي api.example.com، الـ DNS resolver المحلي (عند الـ ISP بتاعك أو 8.8.8.8) بيسأل root nameservers، بعدين TLD nameservers (.com)، وأخيرًا الـ authoritative nameserver للدومين نفسه. النتيجة بتتخزّن في cache لمدة TTL (Time To Live)، وبتتراوح بين 60 ثانية لـ 24 ساعة. لو الـ authoritative records اتغيّرت في النص، الـ resolvers اللي عندهم cache قديم مش هيعرفوا إلا بعد انتهاء الـ TTL — وده بالظبط النافذة اللي المهاجم بيستغلها.
المشكلة باختصار: لو ما عندكش snapshot معتمد للحالة الصحيحة للـ records، مفيش طريقة عملية تكتشف بيها التغيير غير إنك تنتظر شكوى. الحل: تعمل snapshot تلقائي وتقارنه كل 5 دقايق.
الحل في 3 خطوات: snapshot ثم مقارنة ثم تنبيه
الفكرة بسيطة، لكن فيها تفصيلة مهمة. بدل ما تسأل الـ resolver المحلي (اللي بيرجّعلك نتيجة الـ cache)، هتسأل الـ authoritative nameserver مباشرة. كده بتتجنب false negatives سببها cache قديم.
- اعمل query للـ authoritative DNS records للدومينات بتاعتك مباشرة، تجاوزًا للـ resolver cache.
- قارن النتيجة بـ snapshot محفوظ سابقًا في ملف JSON داخل Git.
- لو في فرق، ابعت تنبيه Slack، وحدّث الـ snapshot يدوياً بعد مراجعة بشرية بس.
السكربت كامل (Python 3.12 + dnspython 2.6)
import dns.resolver
import dns.message
import dns.query
import json
import sys
import os
from pathlib import Path
from urllib.request import Request, urlopen
DOMAINS = ["example.com", "api.example.com", "mail.example.com"]
RECORD_TYPES = ["A", "AAAA", "MX", "TXT", "NS", "CNAME"]
SNAPSHOT_PATH = Path("dns_snapshot.json")
SLACK_WEBHOOK = os.environ["SLACK_WEBHOOK_URL"]
def get_authoritative_ns(domain: str) -> list[str]:
parent = ".".join(domain.split(".")[-2:])
answer = dns.resolver.resolve(parent, "NS")
return sorted([str(rr.target).rstrip(".") for rr in answer])
def query_authoritative(domain: str, rtype: str, ns_host: str) -> list[str]:
try:
ns_ip = dns.resolver.resolve(ns_host, "A")[0].to_text()
q = dns.message.make_query(domain, rtype)
r = dns.query.udp(q, ns_ip, timeout=5)
return sorted([str(a).strip() for a in r.answer])
except Exception:
return []
def snapshot_current() -> dict:
result = {}
for domain in DOMAINS:
result[domain] = {}
try:
ns_list = get_authoritative_ns(domain)
except Exception:
continue
primary_ns = ns_list[0]
for rtype in RECORD_TYPES:
answers = query_authoritative(domain, rtype, primary_ns)
if answers:
result[domain][rtype] = answers
return result
def diff(old: dict, new: dict) -> list[str]:
changes = []
all_domains = set(old) | set(new)
for d in sorted(all_domains):
old_d = old.get(d, {})
new_d = new.get(d, {})
for rtype in sorted(set(old_d) | set(new_d)):
if old_d.get(rtype) != new_d.get(rtype):
changes.append(f"{d} [{rtype}] : {old_d.get(rtype)} -> {new_d.get(rtype)}")
return changes
def notify_slack(changes: list[str]):
text = ":rotating_light: DNS Drift Detected:\n" + "\n".join(changes)
body = json.dumps({"text": text}).encode()
req = Request(SLACK_WEBHOOK, data=body, headers={"Content-Type": "application/json"})
urlopen(req, timeout=10)
if __name__ == "__main__":
new_snap = snapshot_current()
if SNAPSHOT_PATH.exists():
old_snap = json.loads(SNAPSHOT_PATH.read_text())
changes = diff(old_snap, new_snap)
if changes:
notify_slack(changes)
print("CHANGES DETECTED:\n" + "\n".join(changes))
sys.exit(1)
SNAPSHOT_PATH.write_text(json.dumps(new_snap, indent=2, sort_keys=True))
print("OK — snapshot up to date.")
السكربت بيتجنب الـ resolver cache بإنه يستخرج الـ NS records الخاصة بكل دومين، بعدين بيستعلم منها مباشرة عبر dns.query.udp. ده الفرق الجوهري بين السكربت ده وأي سكربت بسيط بيستخدم socket.gethostbyname اللي بيعتمد على cache الجهاز.
GitHub Actions workflow
name: DNS Drift Check
on:
schedule:
- cron: "*/5 * * * *"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install dnspython==2.6.1
- name: Run DNS check
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
run: python dns_check.py
- name: Commit baseline if first run
if: github.event_name == 'workflow_dispatch'
run: |
git config user.name "dns-bot"
git config user.email "bot@users.noreply.github.com"
git add dns_snapshot.json
git diff --cached --quiet || git commit -m "Update DNS baseline"
git push
لاحظ إن الـ schedule كل 5 دقايق، لكن الـ commit للـ snapshot بيحصل بس لما تشغّل الـ workflow يدوياً عبر workflow_dispatch. ده مقصود — أي تغيير DNS لازم يعدّي على بشري قبل ما يبقى الحالة الجديدة "المعتمدة".
الأرقام الفعلية المقاسة
- زمن تنفيذ كامل لـ 3 دومينات و 6 أنواع records: 4.2 ثانية متوسط على ubuntu-latest (قياس بـ
time). - تكلفة GitHub Actions: 288 تشغيلة يومياً × 4.2 ثانية ≈ 20 دقيقة شهرياً. ضمن الـ free tier (2,000 دقيقة/شهر للمستودعات الخاصة).
- زمن اكتشاف التغيير من حصوله: ≤5 دقايق (حسب الـ cron interval).
- زمن authoritative query واحد: 80–200ms حسب جغرافيا الـ runner.
- حجم ملف الـ snapshot: ~1.4KB لـ 3 دومينات. حتى 50 دومين هيفضل تحت 25KB.
الـ trade-offs بصراحة
المكسب: بتعرف بأي تغيير في DNS خلال 5 دقايق بصفر تكلفة شهرية، ومعاك audit trail كامل في Git history لكل snapshot.
الثمن: لو فيه تغيير شرعي (نقل سيرفر مثلاً)، السكربت هيدّيك false alarm، ولازم تشغّل الـ workflow_dispatch علشان تحدّث الـ snapshot يدوياً. الناس اللي مش مرتاحة لده بتعتبره overhead. بالنسبة لي ده feature مش bug — أي تغيير DNS مفترض يمر بمراجعة بشرية.
الافتراض اللي مبني عليه السكربت: الـ workflow بيستخدم الـ NS server الأول من القائمة. لو عندك multi-provider DNS (Cloudflare و Route53 معاً على نفس الزون)، لازم تستعلم من الاتنين علشان تتأكد إنهم متطابقين. تعديل الكود بسيط: شيل primary_ns = ns_list[0] واستبدلها بـ loop على ns_list كله، وقارن النتائج.
متى لا تستخدم هذه الطريقة
1. لو دومينك بيستخدم Geo-DNS أو Latency-Based Routing (Route53 مثلاً)، الـ authoritative response هيختلف حسب موقع GitHub runner، فالمقارنة هتبقى noise مستمرة. هتحتاج إنك تستثني الـ records دي أو تستخدم خدمة monitoring متخصصة زي DNSChecker.
2. لو عندك أكتر من 50 دومين، query كل واحد كل 5 دقايق ممكن يستهلك حصة GitHub Actions كاملة. خليها كل 15 دقيقة، أو نقل السكربت لـ AWS Lambda / Cloudflare Workers بـ scheduled trigger.
3. لو الـ DNS provider بتاعك بيوفر audit log تفصيلي و alerts جاهزة (Cloudflare Notifications، Route53 Config Recorder)، استخدمها بدل ما تبني سكربت. الميزة إنها بتدّيك "مين عمل التغيير" مش بس "إن في تغيير".
الخطوة التالية
افتح الـ DNS provider بتاعك دلوقتي (Cloudflare/Route53/Namecheap)، وحدّد الدومينات اللي عايز تراقبها. ضيف السكربت + الـ workflow في ريبو خاص جديد، وشغّل الـ workflow يدوياً مرة واحدة لتأكيد إن الـ baseline اتعمل صح. لو الإشعار الأول وصل Slack بدون أخطاء، الـ pipeline شغّال. عدّل بعد كده DOMAINS في رأس السكربت بأسماء دومينات شركتك الفعلية.