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,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()
@@ -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())
@@ -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
@@ -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).