diff --git a/materials/07-naming-lookup/07-naming-lookup.pdf b/materials/07-naming-lookup/07-naming-lookup.pdf
new file mode 100644
index 0000000..3b856fd
Binary files /dev/null and b/materials/07-naming-lookup/07-naming-lookup.pdf differ
diff --git a/materials/07-naming-lookup/readme.md b/materials/07-naming-lookup/readme.md
new file mode 100644
index 0000000..a9a9d2b
--- /dev/null
+++ b/materials/07-naming-lookup/readme.md
@@ -0,0 +1,10 @@
+## 7. Именование, разрешение имён и поиск
+
+### Лекция
+
+- [Презентация](07-naming-lookup.pdf)
+
+### Семинар
+
+- [Материалы семинара](seminar/readme.md)
+- [Дополнительное чтение](seminar/additional.md)
diff --git a/materials/07-naming-lookup/seminar/.dockerignore b/materials/07-naming-lookup/seminar/.dockerignore
new file mode 100644
index 0000000..5d0f124
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/.dockerignore
@@ -0,0 +1,2 @@
+*
+!Dockerfile
diff --git a/materials/07-naming-lookup/seminar/.gitignore b/materials/07-naming-lookup/seminar/.gitignore
new file mode 100644
index 0000000..568fbb8
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/.gitignore
@@ -0,0 +1,3 @@
+__pycache__/
+*.pyc
+.local/
diff --git a/materials/07-naming-lookup/seminar/Dockerfile b/materials/07-naming-lookup/seminar/Dockerfile
new file mode 100644
index 0000000..629cb6c
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/Dockerfile
@@ -0,0 +1,7 @@
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ python3 python3-libtorrent bind9 bind9-utils dnsutils curl ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
+WORKDIR /lab
+CMD ["sleep", "infinity"]
diff --git a/materials/07-naming-lookup/seminar/additional.md b/materials/07-naming-lookup/seminar/additional.md
new file mode 100644
index 0000000..4415e29
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/additional.md
@@ -0,0 +1,26 @@
+# Дополнительное чтение
+
+## DNS
+
+**DNSSEC** позволяет проверять подлинность и целостность подписанных DNS-данных по цепочке доверия. Шифрование запросов и ответов решается другими механизмами. [Объяснение ICANN](https://www.icann.org/resources/pages/dnssec-what-is-it-why-important-2019-03-05-en).
+
+**GeoDNS и anycast** помогают направлять запросы в разные места обслуживания:
+
+- GeoDNS выбирает DNS-ответ с учётом доступных сведений о местоположении клиента или его резолвера.
+- При anycast один IP-адрес объявляется из нескольких мест, а путь к нему определяется сетевой маршрутизацией.
+
+Примеры: [инфраструктура Dropbox](https://dropbox.tech/infrastructure/dropbox-traffic-infrastructure-edge-network), [карта корневых DNS-серверов](https://root-servers.org/).
+
+## BitTorrent
+
+**Rarest First** — выбор частей, которые реже встречаются у известных клиенту пиров. Клиент оценивает доступность по своим контактам, не зная состояния всей раздачи.
+
+Часть, проверяемая по хешу, передаётся более мелкими **блоками**. Распространённый размер блока — 16 KiB, то есть 16 384 байта. В **Endgame Mode**, при завершении загрузки всего файла, оставшиеся блоки могут запрашиваться у нескольких пиров. После получения блока лишние запросы отменяются.
+
+**Choking/unchoking** управляет тем, каким пирам клиент сейчас отдаёт данные. [Описание обмена: BEP 3](https://www.bittorrent.org/beps/bep_0003.html).
+
+**Mainline DHT** основана на Kademlia и помогает находить контакты участников по infohash. Расстояние между идентификаторами определяется побитовой операцией XOR. При чтении [BEP 5](https://www.bittorrent.org/beps/bep_0005.html) сравните этот способ организации поиска с кольцом Chord.
+
+## Вопрос для сравнения
+
+Где хранятся сведения для поиска в Consul, DNS, трекере BitTorrent и DHT? Кто их обновляет? Какие сведения могут устареть и что сделает клиент, когда очередное обращение не удастся?
diff --git a/materials/07-naming-lookup/seminar/bittorrent/live.py b/materials/07-naming-lookup/seminar/bittorrent/live.py
new file mode 100644
index 0000000..f1ee725
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/bittorrent/live.py
@@ -0,0 +1,170 @@
+"""Real local BitTorrent transfer using libtorrent; no public trackers or peers."""
+import hashlib
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+import socket
+import struct
+import tempfile
+import threading
+import time
+from urllib.parse import parse_qs, urlsplit
+
+import libtorrent as lt
+from metainfo import encode, inspect, make_metainfo
+
+
+class Tracker(ThreadingHTTPServer):
+ daemon_threads = True
+
+ def __init__(self):
+ super().__init__(("127.0.0.1", 6969), Announce)
+ self.enabled = True
+ self.peers = {}
+ self.failed_announces = 0
+ self.infohash = b""
+ self.lock = threading.Lock()
+
+
+class Announce(BaseHTTPRequestHandler):
+ def do_GET(self):
+ tracker = self.server
+ params = parse_qs(urlsplit(self.path).query, encoding="latin-1")
+ if urlsplit(self.path).path != "/announce":
+ self.send_error(404)
+ return
+ with tracker.lock:
+ if not tracker.enabled:
+ tracker.failed_announces += 1
+ self.send_error(503, "Tracker unavailable in this experiment")
+ print("TRACKER 503: rejected announce", flush=True)
+ return
+ try:
+ infohash = params["info_hash"][0].encode("latin-1")
+ port = int(params["port"][0])
+ if infohash != tracker.infohash or not 1 <= port <= 65535:
+ raise ValueError("Unexpected torrent or port")
+ except (KeyError, ValueError):
+ self.send_error(400)
+ return
+ if params.get("event") == ["stopped"]:
+ tracker.peers.pop(port, None)
+ else:
+ tracker.peers[port] = self.client_address[0]
+ # Compact peer list: IPv4 (4 bytes) followed by TCP port (2 bytes).
+ others = [(p, ip) for p, ip in tracker.peers.items() if p != port]
+ body = encode({b"interval": 5, b"min interval": 1,
+ b"peers": b"".join(socket.inet_aton(ip) + struct.pack("!H", p)
+ for p, ip in others)})
+ print(f"TRACKER announce from :{port} -> {[p for p, _ in others]}", flush=True)
+ self.send_response(200)
+ self.send_header("Content-Type", "application/x-bittorrent")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_):
+ pass
+
+
+def client(port, folder, torrent, rate=0):
+ settings = {
+ "listen_interfaces": f"127.0.0.1:{port}",
+ "outgoing_interfaces": "127.0.0.1",
+ "enable_dht": False, "enable_lsd": False,
+ "enable_upnp": False, "enable_natpmp": False,
+ "enable_outgoing_utp": False, "enable_incoming_utp": False,
+ "allow_multiple_connections_per_ip": True,
+ "seeding_outgoing_connections": False,
+ "min_announce_interval": 1,
+ "download_rate_limit": rate,
+ "ignore_limits_on_local_network": False,
+ }
+ # flags=0 avoids installing the default extensions, including ut_pex.
+ session = lt.session(settings, flags=0)
+ params = lt.add_torrent_params()
+ params.ti = lt.torrent_info(str(torrent))
+ params.save_path = str(folder)
+ params.flags &= ~lt.torrent_flags.paused
+ params.flags &= ~lt.torrent_flags.auto_managed
+ handle = session.add_torrent(params)
+ return session, handle
+
+
+def wait_for(label, predicate, handles, timeout=60):
+ deadline, next_print = time.monotonic() + timeout, 0
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ if time.monotonic() >= next_print:
+ for name, handle in handles:
+ status = handle.status()
+ peers = [str(peer.ip) for peer in handle.get_peer_info()]
+ print(f"{label}: {name} {100 * status.progress:.1f}% "
+ f"bytes_done={status.total_done} peers={peers}", flush=True)
+ if status.errc.value():
+ raise RuntimeError(str(status.errc))
+ next_print = time.monotonic() + 2
+ time.sleep(0.2)
+ raise RuntimeError("Timed out: " + label)
+
+
+def run():
+ print("libtorrent", lt.version, "| DHT/LSD/UPnP/NAT-PMP off; no default extensions (PEX)")
+ tracker = Tracker()
+ thread = threading.Thread(target=tracker.serve_forever, daemon=True)
+ thread.start()
+ sessions = []
+ handles = []
+ try:
+ with tempfile.TemporaryDirectory(prefix="seminar7-bt-") as temporary:
+ root = Path(temporary)
+ seed_dir, old_dir, new_dir = (root / x for x in ("seed", "known", "fresh"))
+ for directory in (seed_dir, old_dir, new_dir):
+ directory.mkdir()
+ data = bytes(range(256)) * (4 * 1024 * 1024 // 256)
+ (seed_dir / "lesson.bin").write_bytes(data)
+ raw = encode(make_metainfo("lesson.bin", data, private=True))
+ torrent = root / "lesson.torrent"
+ torrent.write_bytes(raw)
+ tracker.infohash = bytes.fromhex(inspect(raw)[1])
+ session, seed = client(6881, seed_dir, torrent)
+ sessions.append(session)
+ handles.append(seed)
+ wait_for("seed ready", lambda: seed.status().is_seeding and 6881 in tracker.peers,
+ [("seed", seed)])
+ session, known = client(6882, old_dir, torrent, rate=128 * 1024)
+ sessions.append(session)
+ handles.append(known)
+ wait_for("discover and connect", lambda: 0 < known.status().total_done < len(data),
+ [("known", known)])
+ before = known.status().total_done
+ tracker.enabled = False
+ print("\nTRACKER UNAVAILABLE (HTTP 503). Starting a fresh client.", flush=True)
+ session, fresh = client(6883, new_dir, torrent, rate=0)
+ sessions.append(session)
+ handles.append(fresh)
+ wait_for("observe outage",
+ lambda: tracker.failed_announces > 0 and known.status().total_done > before + 65536,
+ [("known", known), ("fresh", fresh)], timeout=30)
+ assert fresh.status().total_done == 0 and fresh.status().num_peers == 0
+ print("PASS: known client made progress; fresh client has zero peers/data", flush=True)
+ sessions[1].apply_settings({"download_rate_limit": 0})
+ wait_for("finish without tracker", lambda: known.status().is_seeding,
+ [("known", known), ("fresh", fresh)])
+ assert hashlib.sha256((old_dir / "lesson.bin").read_bytes()).digest() == hashlib.sha256(data).digest()
+ assert fresh.status().total_done == 0
+ print("PASS: known client finished and SHA-256 matches while tracker is unavailable", flush=True)
+ tracker.enabled = True
+ fresh.force_reannounce()
+ wait_for("tracker restored", lambda: fresh.status().is_seeding, [("fresh", fresh)])
+ assert (new_dir / "lesson.bin").read_bytes() == data
+ print("PASS: fresh client discovered peers after tracker recovery and received exact data", flush=True)
+ for session in sessions:
+ session.pause()
+ finally:
+ tracker.shutdown()
+ tracker.server_close()
+
+
+if __name__ == "__main__":
+ run()
diff --git a/materials/07-naming-lookup/seminar/bittorrent/metadata.py b/materials/07-naming-lookup/seminar/bittorrent/metadata.py
new file mode 100644
index 0000000..9e90c8b
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/bittorrent/metadata.py
@@ -0,0 +1,73 @@
+"""Inspect a torrent or demonstrate unchanged infohash and damaged piece detection."""
+import argparse
+import json
+from pathlib import Path
+import sys
+from metainfo import decode, encode, inspect, make_metainfo, verify
+
+
+def describe(raw):
+ meta, infohash = inspect(raw)
+ info = meta[b"info"]
+ print(json.dumps({
+ "announce": meta[b"announce"].decode(),
+ "name": info[b"name"].decode(),
+ "length": info[b"length"],
+ "piece length": info[b"piece length"],
+ "pieces (SHA-1)": [info[b"pieces"][i:i + 20].hex()
+ for i in range(0, len(info[b"pieces"]), 20)],
+ "infohash (raw bencoded info)": infohash,
+ }, indent=2))
+
+
+def demo(directory):
+ directory.mkdir(parents=True, exist_ok=True)
+ data = bytes(range(256)) * 1024
+ original = encode(make_metainfo("lesson.bin", data))
+ meta, original_info = decode(original)
+ changed = dict(meta)
+ changed[b"announce"] = b"http://127.0.0.1:6970/announce"
+ changed[b"comment"] = b"A different comment"
+ modified = encode(changed)
+ assert decode(modified)[1] == original_info
+ damaged = bytearray(data)
+ damaged[65536 + 17] ^= 1
+ for name, content in [("lesson.bin", data), ("lesson.torrent", original),
+ ("changed.torrent", modified), ("damaged.bin", damaged)]:
+ (directory / name).write_bytes(content)
+ describe(original)
+ print("Original infohash:", inspect(original)[1])
+ print("Changed infohash:", inspect(modified)[1])
+ assert inspect(original)[1] == inspect(modified)[1]
+ good, _ = verify(original, data)
+ bad, indices = verify(original, damaged)
+ assert good and not bad and indices == [1]
+ print("PASS: changing announce/comment preserved infohash")
+ print("PASS: original file verified; damaged file rejected, piece index:", indices)
+ print("Files:", directory)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ subs = parser.add_subparsers(dest="command", required=True)
+ p = subs.add_parser("demo")
+ p.add_argument("--directory", type=Path, default=Path(".local/metadata"))
+ p = subs.add_parser("inspect")
+ p.add_argument("torrent", type=Path)
+ p = subs.add_parser("verify")
+ p.add_argument("torrent", type=Path)
+ p.add_argument("file", type=Path)
+ args = parser.parse_args()
+ if args.command == "demo":
+ demo(args.directory)
+ elif args.command == "inspect":
+ describe(args.torrent.read_bytes())
+ else:
+ ok, bad = verify(args.torrent.read_bytes(), args.file.read_bytes())
+ print("OK" if ok else "FAILED", "bad pieces:", bad)
+ return 0 if ok else 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/materials/07-naming-lookup/seminar/bittorrent/metainfo.py b/materials/07-naming-lookup/seminar/bittorrent/metainfo.py
new file mode 100644
index 0000000..0ad59cc
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/bittorrent/metainfo.py
@@ -0,0 +1,106 @@
+"""Minimal strict bencode reader/writer for the single-file v1 teaching examples."""
+import hashlib
+
+
+def encode(value):
+ if isinstance(value, int):
+ return b"i" + str(value).encode() + b"e"
+ if isinstance(value, bytes):
+ return str(len(value)).encode() + b":" + value
+ if isinstance(value, list):
+ return b"l" + b"".join(encode(v) for v in value) + b"e"
+ if isinstance(value, dict):
+ return b"d" + b"".join(encode(k) + encode(value[k]) for k in sorted(value)) + b"e"
+ raise TypeError(type(value).__name__)
+
+
+def decode(data):
+ """Return decoded value and the EXACT byte slice of the root dictionary's info."""
+ position, raw_info = 0, None
+
+ def read(depth=0):
+ nonlocal position, raw_info
+ if depth > 100 or position >= len(data):
+ raise ValueError("Truncated or deeply nested bencode")
+ token = data[position:position + 1]
+ if token == b"i":
+ end = data.index(b"e", position)
+ number = data[position + 1:end]
+ value = int(number)
+ if str(value).encode() != number:
+ raise ValueError("Non-canonical integer")
+ position = end + 1
+ return value
+ if token in (b"l", b"d"):
+ position += 1
+ result = [] if token == b"l" else {}
+ previous = None
+ while data[position:position + 1] != b"e":
+ if token == b"l":
+ result.append(read(depth + 1))
+ else:
+ key = read(depth + 1)
+ if not isinstance(key, bytes) or (previous is not None and key <= previous):
+ raise ValueError("Dictionary keys must be unique and sorted bytes")
+ previous = key
+ start = position
+ result[key] = read(depth + 1)
+ if depth == 0 and key == b"info":
+ raw_info = data[start:position]
+ position += 1
+ return result
+ colon = data.index(b":", position)
+ length_bytes = data[position:colon]
+ length = int(length_bytes)
+ if length < 0 or str(length).encode() != length_bytes:
+ raise ValueError("Invalid string length")
+ position = colon + 1
+ value = data[position:position + length]
+ if len(value) != length:
+ raise ValueError("Truncated byte string")
+ position += length
+ return value
+
+ result = read()
+ if position != len(data):
+ raise ValueError("Trailing data")
+ return result, raw_info
+
+
+def sha1(data):
+ return hashlib.sha1(data, usedforsecurity=False).digest()
+
+
+def make_metainfo(name, data, announce=b"http://127.0.0.1:6969/announce", private=False):
+ piece_size = 65536
+ info = {b"name": name.encode(), b"length": len(data), b"piece length": piece_size,
+ b"pieces": b"".join(sha1(data[i:i + piece_size]) for i in range(0, len(data), piece_size))}
+ if private:
+ info[b"private"] = 1
+ return {b"announce": announce, b"comment": b"Seminar 7: local teaching data", b"info": info}
+
+
+def inspect(raw):
+ meta, raw_info = decode(raw)
+ if not isinstance(meta, dict) or not isinstance(meta.get(b"info"), dict) or raw_info is None:
+ raise ValueError("Missing info dictionary")
+ info = meta[b"info"]
+ if b"files" in info:
+ raise ValueError("This teaching script supports single-file v1 torrents only")
+ size, length, hashes = info[b"piece length"], info[b"length"], info[b"pieces"]
+ if not isinstance(size, int) or size <= 0 or not isinstance(length, int) or length < 0:
+ raise ValueError("Invalid sizes")
+ if not isinstance(hashes, bytes) or len(hashes) != 20 * ((length + size - 1) // size):
+ raise ValueError("Invalid piece hashes")
+ return meta, sha1(raw_info).hex()
+
+
+def verify(raw, data):
+ meta, _ = inspect(raw)
+ info = meta[b"info"]
+ if len(data) != info[b"length"]:
+ return False, ["file length"]
+ size, hashes = info[b"piece length"], info[b"pieces"]
+ bad = [i // size for i in range(0, len(data), size)
+ if sha1(data[i:i + size]) != hashes[(i // size) * 20:(i // size + 1) * 20]]
+ return not bad, bad
diff --git a/materials/07-naming-lookup/seminar/bittorrent/readme.md b/materials/07-naming-lookup/seminar/bittorrent/readme.md
new file mode 100644
index 0000000..b338961
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/bittorrent/readme.md
@@ -0,0 +1,70 @@
+# BitTorrent: участники, метаданные и проверка данных
+
+В предыдущих опытах мы искали адрес сервиса. Теперь задача другая: найти участников, у которых есть нужный файл, и проверить полученные от них данные.
+
+В BitTorrent участники раздачи называются **пирами**, а участник с полным файлом — **сидом**. **Трекер** сообщает клиенту контакты пиров. Сам файл передаётся между участниками.
+
+Все команды выполняются из каталога `seminar`; [подготовка стенда](../readme.md#самостоятельный-запуск-опытов). Файлы и участники в опытах создаются локально.
+
+## 1. Прочитать метаданные и проверить части
+
+Файл `.torrent` описывает раздачу: содержит адрес трекера, сведения о содержимом и хеши частей. Рассматриваем формат BitTorrent v1 и раздачу одного файла.
+
+**Перед запуском предскажите:**
+
+1. Изменится ли infohash (идентификатор раздачи), если заменить только адрес трекера или комментарий в `.torrent`?
+2. Что обнаружит проверка, если изменить один байт второй части полученного файла?
+
+~~~powershell
+docker compose run --rm -T tools python3 bittorrent/metadata.py demo --directory /work/metadata
+~~~
+
+Скрипт создаёт файл размером 256 KiB и метаданные с частями по 64 KiB, затем выполняет обе проверки. В выводе найдите:
+
+| Поле | Смысл |
+| --- | --- |
+| `announce` | Адрес трекера |
+| `name`, `length` | Имя файла и размер в байтах |
+| `piece length` | Размер части |
+| `pieces` | Хеши частей для проверки полученных данных |
+
+Сведения о файле и частях находятся в словаре `info`. В v1 **infohash вычисляется как SHA-1 от исходных байтов этого словаря**, записанного в формате bencode — формате сериализации метаданных BitTorrent. Адрес трекера и комментарий находятся вне `info`, поэтому их замена сохраняет infohash, если байты `info` не менялись.
+
+Для проверки файла каждая часть хешируется и сравнивается с соответствующим значением из `pieces`. В нашем учебном файле четыре одинаковые части, поэтому их хеши совпадают. После изменения байта проверка обнаруживает повреждение второй части — в выводе это индекс `1`.
+
+Повторите проверку повреждённого файла отдельно:
+
+~~~powershell
+docker compose run --rm -T tools python3 bittorrent/metadata.py verify /work/metadata/lesson.torrent /work/metadata/damaged.bin
+~~~
+
+Ожидаем `FAILED bad pieces: [1]` и код завершения 1. Проверка подтверждает соответствие данных выбранным метаданным; доверие к самим метаданным остаётся отдельным вопросом.
+
+## 2. Отключить трекер во время передачи
+
+**Предскажите:** смогут ли продолжать загрузку клиент с уже найденным пиром и новый клиент, который ещё никого не знает?
+
+В этом опыте другие способы поиска участников отключены: распределённый поиск (DHT), обмен контактами между пирами (PEX) и обнаружение в локальной сети. Новый клиент начинает без сохранённых контактов и может получить их только от трекера.
+
+~~~powershell
+docker compose run --rm -T tools python3 bittorrent/live.py
+~~~
+
+Скрипт запускает трекер и три клиента на основе библиотеки libtorrent. Они передают файл размером 4 MiB:
+
+1. Сид регистрируется на трекере. Клиент `known` получает его контакт и начинает загрузку.
+2. Трекер начинает отвечать HTTP 503 — «сервис недоступен». В этот момент запускается новый клиент `fresh`.
+3. `known` продолжает получать данные и завершает загрузку. У `fresh` нет пиров и данных.
+4. Трекер восстанавливается. После нового запроса `fresh` находит участников и тоже получает файл.
+
+Сопоставьте строки `TRACKER`, `known`, `fresh` и `PASS` с этими этапами. Счётчик `bytes_done` показывает, сколько байтов содержимого уже есть у клиента; завершённые файлы дополнительно сверяются с исходным.
+
+Трекер нужен для получения контактов. Продолжать обмен по уже установленному соединению можно и при его отказе. Результат для нового клиента зависит от условий: в обычной раздаче ему могут помочь другие способы обнаружения участников.
+
+## Поиск без трекера
+
+**Распределённая хеш-таблица (DHT)** позволяет искать контакты участников по infohash без обращения к трекеру. Для входа в DHT клиенту всё равно нужен контакт хотя бы одного её узла.
+
+В BitTorrent Mainline DHT используется Kademlia. В [следующем разделе](../chord/readme.md) разберём Chord — другой алгоритм распределённого поиска из лекции.
+
+Исходники: [чтение метаданных и проверка частей](metainfo.py), [опыты с метаданными](metadata.py), [локальная передача](live.py). Спецификации: [BEP 3](https://www.bittorrent.org/beps/bep_0003.html), [BEP 5](https://www.bittorrent.org/beps/bep_0005.html).
diff --git a/materials/07-naming-lookup/seminar/chord/demo.py b/materials/07-naming-lookup/seminar/chord/demo.py
new file mode 100644
index 0000000..e77af9b
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/chord/demo.py
@@ -0,0 +1,68 @@
+"""A deterministic Chord routing exercise, not a network implementation."""
+import argparse
+from bisect import bisect_left
+
+SIZE = 32
+NODES = [2, 7, 13, 20, 27]
+
+
+def successor(nodes, key):
+ return nodes[bisect_left(nodes, key) % len(nodes)]
+
+
+def fingers(nodes, node):
+ return [(point, successor(nodes, point))
+ for point in ((node + (1 << i)) % SIZE for i in range(5))]
+
+
+def lookup(nodes, start, key, fast=True):
+ current, route = start, [start]
+ for _ in range(len(nodes) + 1):
+ if key == current or len(nodes) == 1:
+ return current, route
+ next_node = nodes[(nodes.index(current) + 1) % len(nodes)]
+ if (key - current) % SIZE <= (next_node - current) % SIZE:
+ return next_node, route
+ candidates = [n for _, n in fingers(nodes, current)
+ if 0 < (n - current) % SIZE < (key - current) % SIZE]
+ current = max(candidates, key=lambda n: (n - current) % SIZE) if fast and candidates else next_node
+ route.append(current)
+ raise RuntimeError("Routing loop")
+
+
+def check():
+ for nodes in (NODES, sorted(NODES + [24]), [2, 7, 13, 27], [2], [0, 31]):
+ for start in nodes:
+ for key in range(SIZE):
+ for fast in (False, True):
+ owner, _ = lookup(nodes, start, key, fast)
+ assert owner == successor(nodes, key), (nodes, start, key, fast)
+ print("PASS: all keys and start nodes, before/after join/failure, singleton and wraparound")
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--check", action="store_true")
+ args = parser.parse_args()
+ if args.check:
+ check()
+ return
+ print("Ring 0..31; nodes:", NODES)
+ for key in (0, 18, 26):
+ print(f"key {key:2} -> owner {successor(NODES, key)}")
+ print("\nFinger table of node 2 (start -> successor):")
+ for point, node in fingers(NODES, 2):
+ print(f"{point:2} -> {node:2}")
+ for fast in (False, True):
+ owner, route = lookup(NODES, 2, 26, fast)
+ print(f"{'fingers' if fast else 'neighbors'}: route {route}; result {owner}; "
+ f"forwarded requests {len(route) - 1}")
+ joined = sorted(NODES + [24])
+ changed = [key for key in range(SIZE) if successor(NODES, key) != successor(joined, key)]
+ print("\nJoin node 24; transferred keys:", changed)
+ print("Fail node 24; after repairing the ring the owner is 27.")
+ print("Routing references do not recreate lost values; replicas are a separate concern.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/materials/07-naming-lookup/seminar/chord/readme.md b/materials/07-naming-lookup/seminar/chord/readme.md
new file mode 100644
index 0000000..617e1dc
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/chord/readme.md
@@ -0,0 +1,52 @@
+# Chord: найти ответственного за ключ
+
+В распределённой хеш-таблице данные распределены между узлами. Чтобы прочитать значение, сначала нужно найти узел, отвечающий за его ключ. Chord решает эту задачу, располагая идентификаторы узлов и ключи на кольце.
+
+В нашем примере пространство идентификаторов — `0…31`, участники — `2, 7, 13, 20, 27`. После `31` снова идёт `0`. За ключ отвечает первый узел, встреченный при движении от ключа по часовой стрелке, включая узел с тем же ID. Он называется **successor ключа**.
+
+Узел знает своего следующего соседа и хранит **finger table** — таблицу ссылок, позволяющую пропускать часть кольца при поиске. Для узла `n` её строки указывают на successor точек `(n + 2^i) mod 32`, где `i = 0…4`.
+
+## Задачи
+
+1. Кто отвечает за ключи `0`, `18`, `26`?
+2. Постройте finger table узла `2`: вычислите пять точек и найдите successor каждой.
+3. Найдите владельца ключа `26`, начиная с узла `2`. Сравните обход по соседям с использованием finger table.
+4. В кольцо вошёл узел `24`. Какие ключи меняют владельца после обновления ссылок?
+5. Затем узел `24` отказал. После восстановления кольца кто отвечает за его ключи? Обязательно ли у него есть значения?
+
+Для задачи 3 вспомните правило поиска. Если ключ находится между текущим узлом и следующим соседом, включая соседа, результат уже известен. Иначе запрос передаётся по самой далёкой ссылке из finger table, которая ещё не достигает ключа при движении по кольцу. Если подходящей ссылки нет, поиск продолжается у следующего соседа. Ключ, совпадающий с ID текущего узла, принадлежит ему самому.
+
+
+Решения и пояснения
+
+За ключи `0`, `18`, `26` отвечают соответственно `2`, `20`, `27`.
+
+| Точка finger table узла 2 | Ссылка на её successor |
+| ---: | ---: |
+| 3 | 7 |
+| 4 | 7 |
+| 6 | 7 |
+| 10 | 13 |
+| 18 | 20 |
+
+Для ключа `26` узел `2` выбирает ссылку на `20`. Узел `20` знает своего следующего соседа `27` и возвращает его как результат поиска.
+
+Маршрут обработки запроса с finger table: `2 → 20`, результат — `27`. Только по соседям: `2 → 7 → 13 → 20`, результат — `27`. Последующее обращение к узлу `27` за значением считается отдельно.
+
+При присоединении `24` к нему переходят ключи `21…24` от узла `27`. Например, следующий сосед узла `20` меняется с `27` на `24`.
+
+После отказа `24` и восстановления кольца за эти ключи снова отвечает `27`. Возможность найти его не означает, что значения сохранились: для этого нужны реплики или другой механизм восстановления данных.
+
+
+
+## Проверить расчёты
+
+Из каталога `seminar`, после [подготовки стенда](../readme.md#самостоятельный-запуск-опытов):
+
+~~~powershell
+docker compose run --rm -T tools python3 chord/demo.py
+~~~
+
+Для этой части можно обойтись без Docker: при установленном Python 3.10+ выполните из того же каталога `python chord/demo.py`.
+
+[Модель](demo.py) рассчитывает таблицы и маршруты для кольца, в котором ссылки уже обновлены. В реальной системе после присоединения или отказа узла они меняются постепенно; этот процесс и хранение значений модель не воспроизводит.
diff --git a/materials/07-naming-lookup/seminar/compose.yaml b/materials/07-naming-lookup/seminar/compose.yaml
new file mode 100644
index 0000000..bcbf403
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/compose.yaml
@@ -0,0 +1,107 @@
+name: distsys-seminar7
+
+x-lab: &lab
+ image: distsys-seminar7-tools:local
+ build: .
+ volumes:
+ - .:/lab:ro
+ - lab-work:/work
+ working_dir: /lab
+ init: true
+
+services:
+ tools:
+ <<: *lab
+ profiles: [tools]
+ consul:
+ image: hashicorp/consul:1.21.5
+ command: agent -dev -client=0.0.0.0 -log-level=warn
+ ports:
+ - "127.0.0.1:18500:8500"
+ healthcheck:
+ test: ["CMD", "consul", "members"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ app-a:
+ <<: *lab
+ command: python3 consul/app.py
+ environment:
+ INSTANCE_ID: app-a
+ CONSUL_URL: http://consul:8500
+ depends_on:
+ consul:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ app-b:
+ <<: *lab
+ command: python3 consul/app.py
+ environment:
+ INSTANCE_ID: app-b
+ CONSUL_URL: http://consul:8500
+ depends_on:
+ consul:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ auth:
+ <<: *lab
+ command: python3 dns/server.py auth
+ networks:
+ default:
+ aliases: [ns.lab.test]
+ healthcheck:
+ test: ["CMD-SHELL", "dig @127.0.0.1 orders.lab.test A +short | grep -q 192.0.2."]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ resolver:
+ <<: *lab
+ command: python3 dns/server.py resolver
+ depends_on:
+ auth:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "rndc -c /tmp/dns-lab/rndc.conf status"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+
+ tld-dns:
+ <<: *lab
+ command: python3 dns/server.py tld
+ networks:
+ default:
+ aliases: [ns.test]
+ depends_on:
+ auth:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "dig @127.0.0.1 test SOA +short | grep -q hostmaster"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+ root-dns:
+ <<: *lab
+ command: python3 dns/server.py root
+ networks:
+ default:
+ aliases: [ns.root]
+ depends_on:
+ tld-dns:
+ condition: service_healthy
+ healthcheck:
+ test: ["CMD-SHELL", "dig @127.0.0.1 . SOA +short | grep -q hostmaster"]
+ interval: 2s
+ timeout: 2s
+ retries: 20
+
+volumes:
+ lab-work:
diff --git a/materials/07-naming-lookup/seminar/consul/app.py b/materials/07-naming-lookup/seminar/consul/app.py
new file mode 100644
index 0000000..b3349d1
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/consul/app.py
@@ -0,0 +1,56 @@
+"""A small real HTTP service which registers its reachable address in Consul."""
+import json
+import os
+import socket
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from urllib.request import Request, urlopen
+
+INSTANCE = os.environ["INSTANCE_ID"]
+CONSUL = os.environ.get("CONSUL_URL", "http://consul:8500")
+ADDRESS = socket.gethostbyname(socket.gethostname())
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ if self.path not in ("/", "/health"):
+ self.send_error(404)
+ return
+ body = json.dumps({"instance": INSTANCE, "address": ADDRESS, "port": 8080}).encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *_):
+ pass
+
+
+def register():
+ payload = {
+ "ID": INSTANCE, "Name": "orders", "Address": ADDRESS, "Port": 8080,
+ "Check": {"HTTP": f"http://{ADDRESS}:8080/health", "Interval": "2s", "Timeout": "1s"},
+ }
+ # No automatic deregistration: the failed service stays in the catalog for comparison.
+ for attempt in range(30):
+ try:
+ request = Request(CONSUL + "/v1/agent/service/register",
+ data=json.dumps(payload).encode(), method="PUT",
+ headers={"Content-Type": "application/json"})
+ with urlopen(request, timeout=2) as response:
+ response.read()
+ print(f"REGISTERED orders / {INSTANCE} -> {ADDRESS}:8080", flush=True)
+ return
+ except OSError:
+ time.sleep(1)
+ raise RuntimeError("Consul registration failed after 30 attempts")
+
+
+if __name__ == "__main__":
+ server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ register()
+ thread.join()
diff --git a/materials/07-naming-lookup/seminar/consul/client.py b/materials/07-naming-lookup/seminar/consul/client.py
new file mode 100644
index 0000000..402b2e0
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/consul/client.py
@@ -0,0 +1,67 @@
+"""Discover instances, save one address, and compare cached/fresh requests."""
+import argparse
+import json
+from pathlib import Path
+import sys
+import time
+from urllib.request import urlopen
+
+BASE = "http://consul:8500"
+CACHE = Path("/work/consul-address.json")
+
+
+def get(url):
+ with urlopen(url, timeout=2) as response:
+ return json.load(response)
+
+
+def discover():
+ rows = get(BASE + "/v1/health/service/orders?passing=true")
+ return sorted([{"id": r["Service"]["ID"], "address": r["Service"]["Address"],
+ "port": r["Service"]["Port"]} for r in rows], key=lambda r: r["id"])
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("action", choices=["list", "save", "cached", "fresh", "wait"])
+ parser.add_argument("--ids", default="app-a,app-b", help="Expected passing IDs for wait")
+ args = parser.parse_args()
+ if args.action == "wait":
+ expected = sorted(args.ids.split(","))
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ try:
+ rows = discover()
+ if [r["id"] for r in rows] == expected:
+ print(json.dumps(rows, indent=2))
+ return
+ except OSError:
+ pass
+ time.sleep(0.5)
+ raise RuntimeError("Passing instances did not reach " + str(expected))
+ if args.action == "list":
+ print(json.dumps(discover(), indent=2))
+ return
+ if args.action == "cached":
+ endpoint = json.loads(CACHE.read_text())
+ else:
+ rows = discover()
+ if not rows:
+ raise RuntimeError("No passing instances")
+ endpoint = rows[0]
+ if args.action == "save":
+ CACHE.parent.mkdir(parents=True, exist_ok=True)
+ CACHE.write_text(json.dumps(endpoint), encoding="utf-8")
+ print("SAVED", endpoint)
+ return
+ url = f"http://{endpoint['address']}:{endpoint['port']}/"
+ print(("CACHED" if args.action == "cached" else "FRESH"), endpoint["id"], url, flush=True)
+ print(json.dumps(get(url), indent=2))
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except (OSError, ValueError, RuntimeError) as error:
+ print("REQUEST FAILED:", error, file=sys.stderr)
+ sys.exit(1)
diff --git a/materials/07-naming-lookup/seminar/consul/readme.md b/materials/07-naming-lookup/seminar/consul/readme.md
new file mode 100644
index 0000000..8666666
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/consul/readme.md
@@ -0,0 +1,90 @@
+# Consul: от имени до ответа приложения
+
+Consul хранит сведения об экземплярах сервисов: их идентификаторы, адреса и результаты проверок работоспособности. Клиент может найти экземпляр по имени сервиса, а затем отправить запрос непосредственно ему.
+
+В стенде у сервиса `orders` два экземпляра: `app-a` и `app-b`. **Предскажите:** если остановить один из них, изменятся ли каталог Consul, список здоровых экземпляров и сохранённый клиентом адрес одновременно?
+
+Все команды выполняются из каталога `seminar`; [подготовка стенда](../readme.md#самостоятельный-запуск-опытов).
+
+## 1. Найти экземпляры
+
+Запустите сервисы и дождитесь, пока оба экземпляра пройдут проверки:
+
+~~~powershell
+docker compose up -d --wait consul app-a app-b
+docker compose run --rm -T tools python3 consul/client.py wait
+~~~
+
+Сравните каталог регистраций и список здоровых экземпляров:
+
+~~~powershell
+docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/catalog/service/orders"
+docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/health/service/orders?passing=true"
+~~~
+
+Каталог содержит зарегистрированные экземпляры независимо от их текущего состояния. Второй запрос с `passing=true` возвращает только экземпляры, прошедшие проверки. В ответе каталога найдите `ServiceName`, `ServiceID`, `ServiceAddress` и `ServicePort`: это имя сервиса, ID экземпляра, его адрес и порт.
+
+Адреса экземпляров можно получить и через DNS-интерфейс Consul:
+
+~~~powershell
+docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul A +short
+docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul SRV +short
+~~~
+
+Запись **A** содержит IPv4-адрес. Строка **SRV** имеет вид `приоритет вес порт имя-узла`, поэтому позволяет узнать и порт приложения — в нашем случае `8080`. Аргумент `-p 8600` задаёт порт самого DNS-сервера Consul.
+
+**Вопрос:** откуда клиент узнает порт приложения, если запросил только A-запись?
+
+## 2. Сохранить адрес и выполнить запрос
+
+~~~powershell
+docker compose run --rm -T tools python3 consul/client.py save
+docker compose run --rm -T tools python3 consul/client.py cached
+~~~
+
+Команда `save` выбирает первый здоровый экземпляр по ID — `app-a` — и сохраняет его адрес. Команда `cached` обращается по сохранённому адресу. В HTTP-ответе поле `instance` показывает, кто обработал запрос.
+
+Таким образом, клиент сначала получает адрес через HTTP API Consul, затем обращается к приложению. DNS-запросы выше иллюстрируют другой способ поиска адреса.
+
+## 3. Остановить приложение и сравнить результаты
+
+Остановите `app-a` и дождитесь, пока Consul обнаружит сбой:
+
+~~~powershell
+docker compose stop app-a
+docker compose run --rm -T tools python3 consul/client.py wait --ids app-b
+~~~
+
+Теперь сравните каталог, список здоровых экземпляров и DNS-ответ:
+
+~~~powershell
+docker compose run --rm -T tools curl -fsS "http://consul:8500/v1/catalog/service/orders"
+docker compose run --rm -T tools python3 consul/client.py list
+docker compose run --rm -T tools dig "@consul" -p 8600 orders.service.consul A +short
+~~~
+
+В каталоге всё ещё есть `app-a`: остановка приложения сама по себе не удаляет регистрацию. Список здоровых экземпляров содержит только `app-b`, а DNS возвращает лишь его адрес.
+
+Сравните запрос по сохранённому адресу с новым поиском:
+
+~~~powershell
+docker compose run --rm -T tools python3 consul/client.py cached
+docker compose run --rm -T tools python3 consul/client.py fresh
+~~~
+
+`cached` завершается с `REQUEST FAILED` и кодом 1 — это ожидаемый результат. `fresh` заново ищет здоровый экземпляр и получает ответ от `app-b`.
+
+Проверки выполняются периодически, поэтому Consul замечает остановку с задержкой. Но даже после обнаружения сбоя сохранённый клиентом адрес не меняется сам. В этом опыте клиент хранит его в файле без срока действия; автоматическое истечение записей рассмотрим в [разделе DNS](../dns/readme.md).
+
+**Вопросы для самопроверки:** гарантирует ли свежий поиск успех следующего запроса? Зачем клиенту тайм-аут и возможность выбрать другой экземпляр? Почему повтор операции с побочным эффектом требует отдельной защиты от повторного выполнения?
+
+## 4. Восстановить исходное состояние
+
+~~~powershell
+docker compose start app-a
+docker compose run --rm -T tools python3 consul/client.py wait
+~~~
+
+В этом стенде один агент Consul и два приложения. Отказоустойчивость самого Consul здесь не проверяется.
+
+Исходники: [приложение и регистрация](app.py), [клиент](client.py). Документация: [регистрация сервиса](https://developer.hashicorp.com/consul/api-docs/agent/service), [DNS-интерфейс](https://developer.hashicorp.com/consul/docs/discover/dns).
diff --git a/materials/07-naming-lookup/seminar/dns/control.py b/materials/07-naming-lookup/seminar/dns/control.py
new file mode 100644
index 0000000..4a08212
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/dns/control.py
@@ -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)
diff --git a/materials/07-naming-lookup/seminar/dns/delegation.py b/materials/07-naming-lookup/seminar/dns/delegation.py
new file mode 100644
index 0000000..e9fa680
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/dns/delegation.py
@@ -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()
diff --git a/materials/07-naming-lookup/seminar/dns/errors.py b/materials/07-naming-lookup/seminar/dns/errors.py
new file mode 100644
index 0000000..cddae23
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/dns/errors.py
@@ -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()
diff --git a/materials/07-naming-lookup/seminar/dns/readme.md b/materials/07-naming-lookup/seminar/dns/readme.md
new file mode 100644
index 0000000..85f9cc2
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/dns/readme.md
@@ -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.
+
+
+Что означают флаги в ответе
+
+- `aa` — ответ авторитетен.
+- `ra` — сервер поддерживает рекурсивные запросы.
+- `rd` — клиент запросил рекурсию. Сам по себе этот флаг не означает, что сервер действительно выполнял такой поиск.
+
+
+
+### Сравнение с публичным 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).
diff --git a/materials/07-naming-lookup/seminar/dns/server.py b/materials/07-naming-lookup/seminar/dns/server.py
new file mode 100644
index 0000000..fa50fb9
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/dns/server.py
@@ -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()
diff --git a/materials/07-naming-lookup/seminar/readme.md b/materials/07-naming-lookup/seminar/readme.md
new file mode 100644
index 0000000..7a35137
--- /dev/null
+++ b/materials/07-naming-lookup/seminar/readme.md
@@ -0,0 +1,69 @@
+# Семинар 7. Имена и поиск
+
+Как по имени найти работающий сервис или нужные данные? Что произойдёт, если найденный адрес устарел? Разберём эти вопросы на примерах Consul, DNS, BitTorrent и Chord.
+
+Материалы дополняют [лекцию 7](../07-naming-lookup.pdf). Каждый раздел содержит объяснения, вопросы и опыты для самостоятельного повторения.
+
+## Темы
+
+| Тема | Что исследуем |
+| --- | --- |
+| [Consul](consul/readme.md) | Поиск экземпляра сервиса и обращение по сохранённому адресу |
+| [DNS](dns/readme.md) | Делегирование, кэширование и ошибки поиска |
+| [BitTorrent](bittorrent/readme.md) | Поиск участников и проверка полученных данных |
+| [Chord](chord/readme.md) | Распределение ключей между узлами и поиск владельца ключа |
+
+## Имя, идентификатор и адрес
+
+У сервиса заказов `orders` работают два экземпляра: `app-a` и `app-b`. Каждый принимает запросы по своему адресу `IP:порт`.
+
+Здесь важно различать три понятия:
+
+- **Имя сервиса** — что мы ищем: `orders`.
+- **Идентификатор экземпляра** — какой именно участник перед нами: `app-a`.
+- **Адрес** — куда отправить запрос этому участнику.
+
+Предположим, `app-a` переехал на другой адрес, сохранив свою идентичность. Имя сервиса и ID экземпляра остаются прежними, но клиенту нужно узнать новый адрес.
+
+Теперь представьте, что экземпляр удалили и заменили новым. Что может произойти, если повторно использовать его ID, а в системе ещё остались старые ссылки или отложенные команды?
+
+В [опыте с Consul](consul/readme.md) начнём с поиска адреса по имени сервиса и проверим, что происходит после остановки найденного экземпляра.
+
+## Самостоятельный запуск опытов
+
+Потребуются Docker и Docker Compose. В Windows Docker Desktop должен работать в режиме Linux containers. Отдельно устанавливать Python, `dig` или клиент BitTorrent для опытов не нужно.
+
+Из корня репозитория перейдите в каталог семинара и подготовьте стенд:
+
+~~~powershell
+cd materials/07-naming-lookup/seminar
+docker compose build tools
+docker compose pull consul
+docker compose up -d --wait
+docker compose ps
+~~~
+
+**Все дальнейшие команды, включая команды в разделах, выполняются из этого каталога.** Для загрузки образов нужен интернет; основные опыты после подготовки работают локально.
+
+Команды вида `docker compose run --rm -T tools …` запускают утилиту внутри учебной сети. Поэтому имена `consul`, `auth` и `resolver` в них доступны, даже если они неизвестны вашей ОС. Кавычки вокруг аргументов вида `"@auth"` сохраняйте: они нужны для PowerShell.
+
+Интерфейс Consul можно открыть в браузере: [localhost:18500](http://localhost:18500). Сами опыты выполняются командами из соответствующих разделов.
+
+После работы остановите стенд:
+
+~~~powershell
+docker compose down
+~~~
+
+Созданные в опыте с метаданными файлы и сохранённый клиентом адрес приложения остаются между запусками. Для полного сброса используйте `docker compose down -v`: команда удалит данные стенда, сохранив файлы репозитория. Если после пересоздания отдельных контейнеров перестали работать обращения между ними, выполните `docker compose down`, затем `docker compose up -d --wait`.
+
+## Вопросы для самопроверки
+
+1. Авторитетный DNS уже выдаёт новый адрес, а клиент использует старый. Где могли сохраниться старые сведения и когда они обновятся?
+2. Consul только что сообщил о здоровом экземпляре. Почему следующий HTTP-запрос всё равно может завершиться ошибкой?
+3. Трекер недоступен. Каким участникам это мешает продолжать загрузку в нашем опыте?
+4. После отказа узла Chord найден новый ответственный за ключ. Достаточно ли этого, чтобы получить значение?
+
+Поиск предоставляет сведения для следующего обращения. Их актуальность нужно поддерживать, а доступность получателя и сохранность данных обеспечиваются отдельными механизмами.
+
+[Дополнительное чтение](additional.md) — DNSSEC, GeoDNS/anycast, DHT BitTorrent и оптимизации обмена.