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