171 lines
7.2 KiB
Python
171 lines
7.2 KiB
Python
"""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()
|