Add week 7 materials

This commit is contained in:
2026-10-21 23:03:28 +03:00
parent 4bb38fb0a8
commit a987b82834
22 changed files with 1265 additions and 0 deletions
@@ -0,0 +1,25 @@
"""Change the authoritative record or flush the resolver's cache."""
import argparse
import ipaddress
import json
from pathlib import Path
import subprocess
from server import ROOT, write_zone
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["reset", "update", "flush", "status"])
parser.add_argument("--address", default="192.0.2.20")
parser.add_argument("--ttl", type=int)
args = parser.parse_args()
if args.ttl is not None and not 1 <= args.ttl <= 3600:
parser.error("TTL must be between 1 and 3600")
if args.action in ("reset", "update"):
previous = json.loads((ROOT / "state.json").read_text())
address = "192.0.2.10" if args.action == "reset" else str(ipaddress.IPv4Address(args.address))
ttl = args.ttl if args.ttl is not None else (60 if args.action == "reset" else previous["ttl"])
write_zone(address, ttl)
subprocess.run(["named-checkzone", "lab.test", str(ROOT / "lab.test.zone")], check=True)
subprocess.run(["rndc", "-c", str(ROOT / "rndc.conf"), "reload", "lab.test"], check=True)
print((ROOT / "state.json").read_text())
else:
subprocess.run(["rndc", "-c", str(ROOT / "rndc.conf"), args.action], check=True)
@@ -0,0 +1,29 @@
"""Trace the real private hierarchy root-dns -> tld-dns -> auth."""
import subprocess
def dig(*args):
command = ["dig", "-r", *args, "+time=2", "+tries=1"]
print("\n$ " + " ".join(command), flush=True)
result = subprocess.run(command, capture_output=True, text=True, timeout=20)
print(result.stdout, flush=True)
if result.returncode:
raise RuntimeError(result.stderr or result.stdout)
return result.stdout
def main():
print("Private hierarchy: root-dns (.) -> tld-dns (test.) -> auth (lab.test.)")
trace = dig("@root-dns", "+trace", "+nodnssec", "orders.lab.test", "A")
assert "192.0.2." in trace and "ns.test." in trace and "ns.lab.test." in trace
referral = dig("@root-dns", "orders.lab.test", "A", "+norecurse")
assert "ANSWER: 0" in referral and "ns.test." in referral
tld = dig("@tld-dns", "orders.lab.test", "A", "+norecurse")
assert "ANSWER: 0" in tld and "ns.lab.test." in tld
final = dig("@auth", "orders.lab.test", "A", "+norecurse")
assert "192.0.2." in final and "flags: qr aa" in final
print("PASS: real root/TLD referrals and final authoritative answer in the private hierarchy")
if __name__ == "__main__":
main()
@@ -0,0 +1,31 @@
"""Obtain four real DNS failure/absence results, including a silent UDP endpoint."""
import socket
import subprocess
def query(server, name, kind="A", port=53):
command = ["dig", "@" + server, "-p", str(port), name, kind,
"+tries=1", "+time=2", "+noall", "+comments", "+answer", "+authority"]
print("\n$ " + " ".join(command), flush=True)
result = subprocess.run(command, capture_output=True, text=True)
print(result.stdout, end="", flush=True)
return result
def main():
nx = query("resolver", "missing.lab.test")
nodata = query("resolver", "orders.lab.test", "AAAA")
failed = query("resolver", "name.broken.test")
# A bound UDP socket receives packets but sends nothing back: no ICMP port-unreachable.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as silent:
silent.bind(("127.0.0.1", 0))
timeout = query("127.0.0.1", "orders.lab.test", port=silent.getsockname()[1])
assert "status: NXDOMAIN" in nx.stdout
assert "status: NOERROR" in nodata.stdout and "ANSWER: 0" in nodata.stdout
assert "status: SERVFAIL" in failed.stdout
assert timeout.returncode == 9 and "timed out" in timeout.stdout
print("PASS: NXDOMAIN / NODATA / SERVFAIL / timeout")
if __name__ == "__main__":
main()
@@ -0,0 +1,135 @@
# DNS: делегирование, кэширование и ошибки
В Consul мы получали адрес из реестра сервисов. Теперь рассмотрим DNS: как найти сервер, который знает нужную запись, и почему разные клиенты могут получать разные адреса одного имени.
**Авторитетный сервер** хранит записи обслуживаемой зоны. **Резолвер** получает ответы для клиента и может сохранять их в кэше. В опытах мы будем обращаться к обоим и сравнивать результаты.
Все команды выполняются из каталога `seminar`; [подготовка стенда](../readme.md#самостоятельный-запуск-опытов).
## 1. Прочитать DNS-ответ
В стенде подготовлена отдельная учебная иерархия:
| Сервер | Роль |
| --- | --- |
| `root-dns` | Обслуживает учебный корень `.` и делегирует зону `test.` |
| `tld-dns` | Обслуживает зону верхнего уровня (TLD) `test.` и делегирует `lab.test.` |
| `auth` | Хранит записи зоны `lab.test.` |
| `resolver` | Получает записи учебной зоны у `auth` и кэширует их |
Делегировать зону — значит указать серверы, отвечающие за неё. Учебная иерархия работает внутри Docker и не меняет публичный DNS.
~~~powershell
docker compose up -d --wait auth resolver root-dns tld-dns
docker compose run --rm -T tools dig "@resolver" orders.lab.test A
docker compose run --rm -T tools dig "@auth" www.lab.test A +norecurse
~~~
В команде `dig` аргумент `@сервер` выбирает получателя запроса, далее идут имя и тип записи. Параметр `+norecurse` отключает запрос рекурсивного поиска: сервер отвечает сведениями, которыми уже располагает.
В первом ответе найдите **A-запись** — IPv4-адрес `orders.lab.test`. Во втором имя `www.lab.test` связано с `orders.lab.test` записью **CNAME**, за которой следует A-запись целевого имени.
При чтении вывода обращайте внимание на:
- `SERVER` — какой сервер ответил;
- `status` — код результата;
- `ANSWER` — записи, отвечающие на запрос;
- число перед `IN` в строке записи — **TTL** — сколько секунд запись ещё можно использовать из кэша.
Адреса `192.0.2.10` и `192.0.2.20` используются как учебные значения записей. Приложений по этим адресам в стенде нет.
## 2. Пройти цепочку делегирований
**Вопрос:** если корневой сервер не хранит адрес `orders.lab.test`, как его ответ помогает продолжить поиск?
~~~powershell
docker compose run --rm -T tools python3 dns/delegation.py
~~~
Скрипт выводит команды и ответы: сначала трассировку `dig +trace`, затем отдельные запросы к корню, TLD-серверу и авторитетному серверу.
В отдельных ответах корня и TLD-сервера секция `ANSWER` пуста. В секции `AUTHORITY` находятся **NS-записи** — имена серверов делегированной зоны. В `ADDITIONAL` даны их адреса, или **glue-записи**, чтобы можно было обратиться к следующему серверу. Последний ответ содержит искомую A-запись.
При `+trace` утилита сама выполняет итеративный поиск: переходит от одного сервера к следующему по делегированиям. Обычный запрос к резолверу может целиком обслужиться из кэша. В нашем опыте `resolver` настроен пересылать запросы учебной зоны сразу к `auth`; трассировка отдельно показывает путь через корень и TLD.
<details>
<summary>Что означают флаги в ответе</summary>
- `aa` — ответ авторитетен.
- `ra` — сервер поддерживает рекурсивные запросы.
- `rd` — клиент запросил рекурсию. Сам по себе этот флаг не означает, что сервер действительно выполнял такой поиск.
</details>
### Сравнение с публичным DNS
~~~powershell
docker compose run --rm -T tools dig wikipedia.org A
docker compose run --rm -T tools dig "@1.1.1.1" wikipedia.org A +trace +time=2 +tries=1
~~~
В конце полной трассировки должна появиться A-запись `wikipedia.org`. Если вывод заканчивается раньше, поиск не завершён, даже если команда вернула код 0. Публичные записи меняются, а сеть может ограничивать прямые обращения к внешним DNS-серверам. Для разбора делегирований в таком случае используйте локальную иерархию.
## 3. Изменить адрес и наблюдать старый TTL
**Предскажите:** если одновременно изменить адрес и уменьшить TTL с 60 до 5 секунд, когда изменится ответ уже заполненного кэша?
Верните исходную запись командой `reset`, очистите кэш командой `flush` и запросите адрес:
~~~powershell
docker compose exec -T auth python3 dns/control.py reset
docker compose exec -T resolver python3 dns/control.py flush
docker compose run --rm -T tools dig "@resolver" orders.lab.test A +noall +answer
~~~
Параметры `+noall +answer` оставляют в выводе только записи ответа. Получим `192.0.2.10` с TTL около 60. Отсчитывая время от этого запроса, примерно через 20 секунд измените запись и сравните ответы:
~~~powershell
docker compose exec -T auth python3 dns/control.py update --address 192.0.2.20 --ttl 5
docker compose run --rm -T tools dig "@auth" orders.lab.test A +noall +answer
docker compose run --rm -T tools dig "@resolver" orders.lab.test A +noall +answer
~~~
Авторитетный сервер уже выдаёт `192.0.2.20` с TTL 5. Резолвер продолжает возвращать `192.0.2.10` с остатком прежних 60 секунд. Уменьшение TTL на авторитетном сервере не меняет срок ранее сохранённой записи. Повторный запрос к кэшу тоже не начинает отсчёт заново.
Дождитесь истечения **TTL старого ответа** и повторите запрос:
~~~powershell
docker compose run --rm -T tools dig "@resolver" orders.lab.test A +noall +answer
~~~
Теперь ожидаем `192.0.2.20` с TTL не больше 5. Если при выполнении предыдущих команд прошла минута и старый адрес уже исчез, повторите опыт с `reset` и `flush`. Для более длинного опыта можно задать `reset --ttl 120`.
Здесь кэш настроен без предварительного обновления и выдачи просроченных записей. После истечения TTL следующий запрос клиента заставляет резолвер получить запись заново.
**Вопросы для самопроверки:** когда уменьшать TTL перед переездом сервиса? Почему старый адрес нужно поддерживать некоторое время? Где ещё, кроме резолвера, могут кэшироваться адреса?
Восстановите исходную запись и очистите кэш:
~~~powershell
docker compose exec -T auth python3 dns/control.py reset
docker compose exec -T resolver python3 dns/control.py flush
~~~
## 4. Различить отсутствие записи и ошибку поиска
Отсутствие адреса в ответе может означать разные вещи. Сравните четыре результата:
~~~powershell
docker compose run --rm -T tools python3 dns/errors.py
~~~
| Запрос / условие | Результат | Что можно заключить |
| --- | --- | --- |
| `missing.lab.test A` | `NXDOMAIN` | Запрошенного имени нет |
| `orders.lab.test AAAA` | `NOERROR`, пустая секция `ANSWER` | Имя существует, но IPv6-адреса для него нет |
| `name.broken.test A` через резолвер | `SERVFAIL` | Резолвер не смог получить пригодный ответ |
| На запрос никто не отвечает | Тайм-аут | Ответ не получен за отведённое время |
**NODATA** — название ситуации, когда имя существует, но записи нужного типа нет; отдельного кода DNS с таким именем нет. В нашем примере запрос AAAA возвращает именно такой результат.
SERVFAIL в опыте возникает из-за пересылки запроса серверу, который не обслуживает нужную зону. Ни SERVFAIL, ни тайм-аут не доказывают отсутствия имени.
**Вопрос:** почему недавно созданное имя может некоторое время оставаться недоступным клиентам, которые запрашивали его до создания? Учтите, что ответы об отсутствии имени тоже могут кэшироваться.
Исходники: [настройка серверов](server.py), [изменение записи и сброс кэша](control.py), [делегирование](delegation.py), [ошибки](errors.py). Справочник: [BIND 9](https://bind9.readthedocs.io/en/v9.18.39/reference.html).
@@ -0,0 +1,69 @@
"""Run BIND as an authoritative server or a separate caching resolver."""
import json
import os
from pathlib import Path
import socket
import sys
import time
ROOT = Path("/tmp/dns-lab")
# A fixed key is only used by rndc on loopback inside these disposable lab containers.
KEY = 'key "lab-control" { algorithm hmac-sha256; secret "c2VtaW5hci03LWxvY2FsLWRucy1jb250cm9sLWtleSE="; };'
def write_zone(address, ttl):
old = json.loads((ROOT / "state.json").read_text()) if (ROOT / "state.json").exists() else {}
serial = max(int(time.time()), old.get("serial", 0) + 1)
(ROOT / "lab.test.zone").write_text(f"""$TTL {ttl}
@ IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {ttl})
IN NS ns.lab.test.
ns IN A {socket.gethostbyname(socket.gethostname())}
orders IN A {address}
www IN CNAME orders.lab.test.
""")
(ROOT / "state.json").write_text(json.dumps({"address": address, "ttl": ttl, "serial": serial}))
def main():
role = sys.argv[1]
ROOT.mkdir(parents=True, exist_ok=True)
control = KEY + '\ncontrols { inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "lab-control"; }; };\n'
(ROOT / "rndc.conf").write_text(KEY + '\noptions { default-server 127.0.0.1; default-key "lab-control"; };\n')
common = f"""directory "{ROOT}";
listen-on {{ any; }};
listen-on-v6 {{ none; }};
allow-query {{ any; }};
minimal-responses no;
dnssec-validation no;
empty-zones-enable no;
pid-file "{ROOT}/named.pid";
session-keyfile "{ROOT}/session.key";
"""
if role == "auth":
write_zone("192.0.2.10", 60)
options = "recursion no;"
zones = f'zone "lab.test" {{ type primary; file "{ROOT}/lab.test.zone"; }};'
elif role in ("root", "tld"):
address = socket.gethostbyname(socket.gethostname())
zone, ns = (".", "ns.root.") if role == "root" else ("test", "ns.test.")
child, child_ns, child_host = ("test.", "ns.test.", "tld-dns") if role == "root" else ("lab.test.", "ns.lab.test.", "auth")
child_address = socket.gethostbyname(child_host)
records = f"$TTL 60\n@ IN SOA {ns} hostmaster.{ns} (1 60 60 3600 60)\n@ IN NS {ns}\n{ns} IN A {address}\n{child} IN NS {child_ns}\n{child_ns} IN A {child_address}\n"
(ROOT / "hierarchy.zone").write_text(records)
options = "recursion no;"
zones = f'zone "{zone}" {{ type primary; file "{ROOT}/hierarchy.zone"; }};'
elif role == "resolver":
address = socket.gethostbyname("auth")
options = """recursion yes; allow-recursion { any; }; allow-query-cache { any; };
prefetch 0; stale-answer-enable no; stale-cache-enable no; servfail-ttl 0;"""
zones = "\n".join(f'zone "{name}" {{ type forward; forward only; forwarders {{ {address}; }}; }};'
for name in ("lab.test", "broken.test"))
else:
raise ValueError("Unknown DNS role")
config = control + f"options {{ {common}\n{options} }};\n" + zones
(ROOT / "named.conf").write_text(config)
os.execvp("named", ["named", "-g", "-c", str(ROOT / "named.conf"), "-u", "root", "-n", "1"])
if __name__ == "__main__":
main()