Add week 7 materials
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user