Reactor/loop-pool engine with TCP/µTP/MSE transports, per-connection pipelining, priority-driven piece selection with endgame, and the Python FFI test harness. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
498 lines
20 KiB
Python
498 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import ctypes as C
|
|
import os
|
|
import random
|
|
import socket
|
|
import ssl
|
|
import struct
|
|
import time
|
|
from dataclasses import dataclass
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
from urllib.request import Request, urlopen
|
|
|
|
TRACKER_OK = 0
|
|
TRACKER_EVENT_NONE = 0
|
|
TRACKER_EVENT_COMPLETED = 1
|
|
TRACKER_EVENT_STARTED = 2
|
|
TRACKER_EVENT_STOPPED = 3
|
|
TRACKER_ADDR_IPV4 = 4
|
|
TRACKER_ADDR_IPV6 = 6
|
|
TRACKER_MAX_PEERS = 256
|
|
TRACKER_MAX_URL_DATA = 512
|
|
DHT_MAX_TRANSACTION = 16
|
|
DHT_MAX_TOKEN = 64
|
|
DHT_MAX_NODES = 256
|
|
DHT_MAX_ERROR = 128
|
|
DHT_MSG_QUERY = 1
|
|
DHT_MSG_RESPONSE = 2
|
|
DHT_QUERY_GET_PEERS = 3
|
|
DEFAULT_DHT_BOOTSTRAP = (
|
|
("router.bittorrent.com", 6881),
|
|
("dht.transmissionbt.com", 6881),
|
|
("router.utorrent.com", 6881),
|
|
)
|
|
|
|
|
|
class TrackerPeer(C.Structure):
|
|
_fields_ = [
|
|
("family", C.c_uint8),
|
|
("addr", C.c_uint8 * 16),
|
|
("port", C.c_uint16),
|
|
("peer_id", C.c_uint8 * 20),
|
|
("has_peer_id", C.c_uint8),
|
|
]
|
|
|
|
|
|
class TrackerAnnounceRequest(C.Structure):
|
|
_fields_ = [
|
|
("info_hash", C.c_uint8 * 20),
|
|
("peer_id", C.c_uint8 * 20),
|
|
("port", C.c_uint16),
|
|
("uploaded", C.c_uint64),
|
|
("downloaded", C.c_uint64),
|
|
("left", C.c_uint64),
|
|
("numwant", C.c_int32),
|
|
("key", C.c_uint32),
|
|
("ip4", C.c_uint32),
|
|
("event", C.c_int),
|
|
("compact", C.c_uint8),
|
|
("no_peer_id", C.c_uint8),
|
|
("has_key", C.c_uint8),
|
|
("has_ip4", C.c_uint8),
|
|
("ip", C.c_char * 64),
|
|
("tracker_id", C.c_char * 128),
|
|
("url_data", C.c_char * TRACKER_MAX_URL_DATA),
|
|
]
|
|
|
|
|
|
class TrackerAnnounceResponse(C.Structure):
|
|
_fields_ = [
|
|
("interval", C.c_uint32),
|
|
("min_interval", C.c_uint32),
|
|
("complete", C.c_uint32),
|
|
("incomplete", C.c_uint32),
|
|
("tracker_id", C.c_char_p),
|
|
("peers", C.POINTER(TrackerPeer)),
|
|
("peer_count", C.c_size_t),
|
|
("compact", C.c_uint8),
|
|
]
|
|
|
|
|
|
class DHTNode(C.Structure):
|
|
_fields_ = [
|
|
("id", C.c_uint8 * 20),
|
|
("family", C.c_uint8),
|
|
("addr", C.c_uint8 * 16),
|
|
("port", C.c_uint16),
|
|
]
|
|
|
|
|
|
class DHTMessage(C.Structure):
|
|
_fields_ = [
|
|
("type", C.c_int),
|
|
("query", C.c_int),
|
|
("transaction", C.c_uint8 * DHT_MAX_TRANSACTION),
|
|
("transaction_len", C.c_size_t),
|
|
("id", C.c_uint8 * 20),
|
|
("target", C.c_uint8 * 20),
|
|
("info_hash", C.c_uint8 * 20),
|
|
("port", C.c_uint16),
|
|
("implied_port", C.c_uint8),
|
|
("want_ipv4", C.c_uint8),
|
|
("want_ipv6", C.c_uint8),
|
|
("token", C.c_uint8 * DHT_MAX_TOKEN),
|
|
("token_len", C.c_size_t),
|
|
("nodes", DHTNode * DHT_MAX_NODES),
|
|
("node_count", C.c_size_t),
|
|
("peers", TrackerPeer * TRACKER_MAX_PEERS),
|
|
("peer_count", C.c_size_t),
|
|
("error_code", C.c_int),
|
|
("error_message", C.c_char * DHT_MAX_ERROR),
|
|
]
|
|
|
|
|
|
@dataclass
|
|
class TrackerResult:
|
|
tracker: str
|
|
ok: bool
|
|
protocol: str
|
|
peers: list[tuple[str, int]]
|
|
interval: int = 0
|
|
complete: int = 0
|
|
incomplete: int = 0
|
|
error: str = ""
|
|
elapsed_ms: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class DHTResult:
|
|
peers: list[tuple[str, int]]
|
|
nodes_queried: int
|
|
nodes_discovered: int
|
|
elapsed_ms: float
|
|
error: str = ""
|
|
|
|
|
|
def _default_lib_path() -> str:
|
|
env = os.environ.get("TORRENT_TRACKER_LIB")
|
|
if env:
|
|
return env
|
|
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
candidates = [
|
|
os.path.join(root, "..", "torrent-tracker", "build", "libtorrenttracker.so"),
|
|
os.path.join(root, "build", "libtorrenttracker.so"),
|
|
]
|
|
for path in candidates:
|
|
if os.path.exists(path):
|
|
return os.path.abspath(path)
|
|
return os.path.abspath(candidates[0])
|
|
|
|
|
|
def _load(lib_path: str | None = None) -> C.CDLL:
|
|
lib = C.CDLL(lib_path or _default_lib_path())
|
|
lib.tracker_http_write_announce_query.restype = C.c_int
|
|
lib.tracker_http_write_announce_query.argtypes = [
|
|
C.POINTER(TrackerAnnounceRequest), C.c_char_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
|
lib.tracker_http_parse_announce_response.restype = C.c_int
|
|
lib.tracker_http_parse_announce_response.argtypes = [
|
|
C.c_void_p, C.c_size_t, C.POINTER(TrackerPeer), C.c_size_t,
|
|
C.POINTER(TrackerAnnounceResponse)]
|
|
lib.tracker_udp_write_connect_request.restype = C.c_int
|
|
lib.tracker_udp_write_connect_request.argtypes = [
|
|
C.c_uint32, C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
|
lib.tracker_udp_parse_connect_response.restype = C.c_int
|
|
lib.tracker_udp_parse_connect_response.argtypes = [
|
|
C.c_void_p, C.c_size_t, C.c_uint32, C.POINTER(C.c_uint64)]
|
|
lib.tracker_udp_write_announce_request.restype = C.c_int
|
|
lib.tracker_udp_write_announce_request.argtypes = [
|
|
C.c_uint64, C.c_uint32, C.POINTER(TrackerAnnounceRequest),
|
|
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
|
lib.tracker_udp_parse_announce_response.restype = C.c_int
|
|
lib.tracker_udp_parse_announce_response.argtypes = [
|
|
C.c_void_p, C.c_size_t, C.c_uint32, C.c_int, C.POINTER(TrackerPeer),
|
|
C.c_size_t, C.POINTER(TrackerAnnounceResponse)]
|
|
lib.dht_write_get_peers_query.restype = C.c_int
|
|
lib.dht_write_get_peers_query.argtypes = [
|
|
C.c_void_p, C.c_size_t, C.c_void_p, C.c_void_p, C.c_uint8, C.c_uint8,
|
|
C.c_void_p, C.c_size_t, C.POINTER(C.c_size_t)]
|
|
lib.dht_parse_message.restype = C.c_int
|
|
lib.dht_parse_message.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(DHTMessage)]
|
|
lib.dht_write_peers_response.restype = C.c_int
|
|
lib.dht_write_peers_response.argtypes = [
|
|
C.c_void_p, C.c_size_t, C.c_void_p, C.c_void_p, C.c_size_t,
|
|
C.POINTER(TrackerPeer), C.c_size_t, C.c_void_p, C.c_size_t,
|
|
C.POINTER(C.c_size_t)]
|
|
return lib
|
|
|
|
|
|
class TrackerClient:
|
|
def __init__(self, lib_path: str | None = None):
|
|
self.lib = _load(lib_path)
|
|
|
|
def _request(self, meta, peer_id: bytes, port: int, key: int, numwant: int,
|
|
event: str) -> TrackerAnnounceRequest:
|
|
req = TrackerAnnounceRequest()
|
|
C.memset(C.byref(req), 0, C.sizeof(req))
|
|
req.info_hash[:] = meta.info_hash
|
|
req.peer_id[:] = peer_id
|
|
req.port = port
|
|
req.left = meta.total_size
|
|
req.numwant = numwant
|
|
req.key = key
|
|
req.has_key = 1
|
|
req.compact = 1
|
|
req.no_peer_id = 1
|
|
req.event = {
|
|
"completed": TRACKER_EVENT_COMPLETED,
|
|
"started": TRACKER_EVENT_STARTED,
|
|
"stopped": TRACKER_EVENT_STOPPED,
|
|
}.get(event, TRACKER_EVENT_NONE)
|
|
return req
|
|
|
|
@staticmethod
|
|
def _peers(peers, count: int) -> list[tuple[str, int]]:
|
|
out: list[tuple[str, int]] = []
|
|
for i in range(count):
|
|
p = peers[i]
|
|
if p.family == TRACKER_ADDR_IPV4:
|
|
host = socket.inet_ntop(socket.AF_INET, bytes(p.addr[:4]))
|
|
elif p.family == TRACKER_ADDR_IPV6:
|
|
host = socket.inet_ntop(socket.AF_INET6, bytes(p.addr[:16]))
|
|
else:
|
|
continue
|
|
out.append((host, int(p.port)))
|
|
return out
|
|
|
|
def announce_http(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
|
numwant: int, event: str, timeout: float) -> TrackerResult:
|
|
start = time.monotonic()
|
|
try:
|
|
req = self._request(meta, peer_id, port, key, numwant, event)
|
|
query = C.create_string_buffer(2048)
|
|
written = C.c_size_t()
|
|
rc = self.lib.tracker_http_write_announce_query(
|
|
C.byref(req), query, C.sizeof(query), C.byref(written))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"tracker_http_write_announce_query failed: {rc}")
|
|
|
|
parts = urlsplit(url)
|
|
q = parts.query
|
|
suffix = query.value.decode("ascii")
|
|
q = f"{q}&{suffix}" if q else suffix
|
|
announce_url = urlunsplit((parts.scheme, parts.netloc, parts.path, q,
|
|
parts.fragment))
|
|
request = Request(announce_url,
|
|
headers={"User-Agent": "torrent-peer/0.1"})
|
|
ctx = ssl.create_default_context()
|
|
with urlopen(request, timeout=timeout, context=ctx) as resp:
|
|
raw = resp.read(2 * 1024 * 1024)
|
|
|
|
raw_buf = C.create_string_buffer(raw, len(raw))
|
|
out_peers = (TrackerPeer * TRACKER_MAX_PEERS)()
|
|
parsed = TrackerAnnounceResponse()
|
|
rc = self.lib.tracker_http_parse_announce_response(
|
|
raw_buf, len(raw), out_peers, TRACKER_MAX_PEERS, C.byref(parsed))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"tracker_http_parse_announce_response failed: {rc}")
|
|
return TrackerResult(
|
|
tracker=url,
|
|
ok=True,
|
|
protocol=parts.scheme,
|
|
peers=self._peers(out_peers, parsed.peer_count),
|
|
interval=int(parsed.interval),
|
|
complete=int(parsed.complete),
|
|
incomplete=int(parsed.incomplete),
|
|
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
|
)
|
|
except Exception as exc:
|
|
return TrackerResult(url, False, "http", [], error=str(exc),
|
|
elapsed_ms=(time.monotonic() - start) * 1000.0)
|
|
|
|
@staticmethod
|
|
def _url_data(url: str) -> bytes:
|
|
parts = urlsplit(url)
|
|
data = (parts.path or "").encode("utf-8")
|
|
if parts.query:
|
|
data += b"?" + parts.query.encode("utf-8")
|
|
return data[:TRACKER_MAX_URL_DATA - 1]
|
|
|
|
@staticmethod
|
|
def _roundtrip(sock: socket.socket, packet: bytes, txid: int,
|
|
timeout: float) -> bytes:
|
|
deadline = time.monotonic() + timeout
|
|
delay = min(timeout, 1.0)
|
|
while True:
|
|
sock.send(packet)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise TimeoutError("UDP tracker timed out")
|
|
sock.settimeout(min(delay, remaining))
|
|
try:
|
|
raw = sock.recv(65535)
|
|
except socket.timeout:
|
|
delay = min(delay * 2.0, 8.0)
|
|
continue
|
|
if len(raw) >= 8 and struct.unpack_from("!I", raw, 4)[0] == txid:
|
|
return raw
|
|
|
|
def announce_udp(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
|
numwant: int, event: str, timeout: float) -> TrackerResult:
|
|
start = time.monotonic()
|
|
parts = urlsplit(url)
|
|
if not parts.hostname:
|
|
return TrackerResult(url, False, "udp", [], error="missing UDP tracker host")
|
|
tracker_port = parts.port or 80
|
|
try:
|
|
infos = socket.getaddrinfo(parts.hostname, tracker_port, 0,
|
|
socket.SOCK_DGRAM)
|
|
last_error: Exception | None = None
|
|
for family, socktype, proto, _canon, sockaddr in infos:
|
|
if family not in (socket.AF_INET, socket.AF_INET6):
|
|
continue
|
|
try:
|
|
with socket.socket(family, socktype, proto) as sock:
|
|
sock.connect(sockaddr)
|
|
|
|
txid = random.getrandbits(32)
|
|
buf = C.create_string_buffer(2048)
|
|
written = C.c_size_t()
|
|
rc = self.lib.tracker_udp_write_connect_request(
|
|
txid, buf, C.sizeof(buf), C.byref(written))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"connect request failed: {rc}")
|
|
raw = self._roundtrip(sock, buf.raw[:written.value], txid, timeout)
|
|
conn_id = C.c_uint64()
|
|
raw_buf = C.create_string_buffer(raw, len(raw))
|
|
rc = self.lib.tracker_udp_parse_connect_response(
|
|
raw_buf, len(raw), txid, C.byref(conn_id))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"connect response failed: {rc}")
|
|
|
|
req = self._request(meta, peer_id, port, key, numwant, event)
|
|
url_data = self._url_data(url)
|
|
if url_data:
|
|
req.url_data = url_data
|
|
txid = random.getrandbits(32)
|
|
rc = self.lib.tracker_udp_write_announce_request(
|
|
conn_id.value, txid, C.byref(req), buf, C.sizeof(buf),
|
|
C.byref(written))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"announce request failed: {rc}")
|
|
raw = self._roundtrip(sock, buf.raw[:written.value], txid, timeout)
|
|
raw_buf = C.create_string_buffer(raw, len(raw))
|
|
out_peers = (TrackerPeer * TRACKER_MAX_PEERS)()
|
|
parsed = TrackerAnnounceResponse()
|
|
tracker_family = (TRACKER_ADDR_IPV6 if family == socket.AF_INET6
|
|
else TRACKER_ADDR_IPV4)
|
|
rc = self.lib.tracker_udp_parse_announce_response(
|
|
raw_buf, len(raw), txid, tracker_family, out_peers,
|
|
TRACKER_MAX_PEERS, C.byref(parsed))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"announce response failed: {rc}")
|
|
return TrackerResult(
|
|
tracker=url,
|
|
ok=True,
|
|
protocol="udp",
|
|
peers=self._peers(out_peers, parsed.peer_count),
|
|
interval=int(parsed.interval),
|
|
complete=int(parsed.complete),
|
|
incomplete=int(parsed.incomplete),
|
|
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
|
)
|
|
except Exception as exc:
|
|
last_error = exc
|
|
continue
|
|
raise RuntimeError(str(last_error or "no usable tracker address"))
|
|
except Exception as exc:
|
|
return TrackerResult(url, False, "udp", [], error=str(exc),
|
|
elapsed_ms=(time.monotonic() - start) * 1000.0)
|
|
|
|
def announce(self, url: str, meta, peer_id: bytes, port: int, key: int,
|
|
numwant: int = 50, event: str = "started",
|
|
timeout: float = 8.0) -> TrackerResult:
|
|
scheme = urlsplit(url).scheme.lower()
|
|
if scheme in ("http", "https"):
|
|
return self.announce_http(url, meta, peer_id, port, key, numwant,
|
|
event, timeout)
|
|
if scheme == "udp":
|
|
return self.announce_udp(url, meta, peer_id, port, key, numwant,
|
|
event, timeout)
|
|
return TrackerResult(url, False, scheme or "unknown", [],
|
|
error=f"unsupported tracker scheme {scheme!r}")
|
|
|
|
|
|
class DHTClient:
|
|
def __init__(self, lib_path: str | None = None,
|
|
bootstrap: tuple[tuple[str, int], ...] = DEFAULT_DHT_BOOTSTRAP):
|
|
self.lib = _load(lib_path)
|
|
self.bootstrap = bootstrap
|
|
self.node_id = os.urandom(20)
|
|
self._tx = random.randrange(1, 0xffff)
|
|
|
|
@staticmethod
|
|
def _peer_endpoint(peer: TrackerPeer) -> tuple[str, int] | None:
|
|
if peer.family == TRACKER_ADDR_IPV4:
|
|
host = socket.inet_ntop(socket.AF_INET, bytes(peer.addr[:4]))
|
|
elif peer.family == TRACKER_ADDR_IPV6:
|
|
host = socket.inet_ntop(socket.AF_INET6, bytes(peer.addr[:16]))
|
|
else:
|
|
return None
|
|
return host, int(peer.port)
|
|
|
|
@staticmethod
|
|
def _node_endpoint(node: DHTNode) -> tuple[str, int] | None:
|
|
if node.family == TRACKER_ADDR_IPV4:
|
|
host = socket.inet_ntop(socket.AF_INET, bytes(node.addr[:4]))
|
|
elif node.family == TRACKER_ADDR_IPV6:
|
|
host = socket.inet_ntop(socket.AF_INET6, bytes(node.addr[:16]))
|
|
else:
|
|
return None
|
|
return host, int(node.port)
|
|
|
|
def _next_tx(self) -> bytes:
|
|
self._tx = (self._tx + 1) & 0xffff
|
|
return self._tx.to_bytes(2, "big")
|
|
|
|
def _get_peers_packet(self, info_hash: bytes, tx: bytes) -> bytes:
|
|
buf = C.create_string_buffer(2048)
|
|
written = C.c_size_t()
|
|
tx_buf = C.create_string_buffer(tx, len(tx))
|
|
id_buf = C.create_string_buffer(self.node_id, len(self.node_id))
|
|
hash_buf = C.create_string_buffer(info_hash, len(info_hash))
|
|
rc = self.lib.dht_write_get_peers_query(
|
|
tx_buf, len(tx), id_buf, hash_buf, 1, 1, buf, C.sizeof(buf),
|
|
C.byref(written))
|
|
if rc != TRACKER_OK:
|
|
raise RuntimeError(f"dht_write_get_peers_query failed: {rc}")
|
|
return buf.raw[:written.value]
|
|
|
|
def lookup(self, info_hash: bytes, *, timeout: float = 6.0,
|
|
max_queries: int = 32, max_peers: int = 100) -> DHTResult:
|
|
start = time.monotonic()
|
|
deadline = start + timeout
|
|
peers: set[tuple[str, int]] = set()
|
|
queue: list[tuple[str, int]] = list(self.bootstrap)
|
|
seen_nodes: set[tuple[str, int]] = set()
|
|
queried = 0
|
|
discovered = 0
|
|
last_error = ""
|
|
|
|
while queue and queried < max_queries and len(peers) < max_peers:
|
|
if time.monotonic() >= deadline:
|
|
break
|
|
host, port = queue.pop(0)
|
|
if (host, port) in seen_nodes:
|
|
continue
|
|
seen_nodes.add((host, port))
|
|
queried += 1
|
|
|
|
remaining = max(0.05, deadline - time.monotonic())
|
|
try:
|
|
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_DGRAM)
|
|
except OSError as exc:
|
|
last_error = str(exc)
|
|
continue
|
|
|
|
for family, socktype, proto, _canon, sockaddr in infos:
|
|
if family not in (socket.AF_INET, socket.AF_INET6):
|
|
continue
|
|
tx = self._next_tx()
|
|
packet = self._get_peers_packet(info_hash, tx)
|
|
try:
|
|
with socket.socket(family, socktype, proto) as sock:
|
|
sock.settimeout(min(1.0, remaining))
|
|
sock.sendto(packet, sockaddr)
|
|
raw, _addr = sock.recvfrom(4096)
|
|
except OSError as exc:
|
|
last_error = str(exc)
|
|
continue
|
|
|
|
msg = DHTMessage()
|
|
raw_buf = C.create_string_buffer(raw, len(raw))
|
|
rc = self.lib.dht_parse_message(raw_buf, len(raw), C.byref(msg))
|
|
if rc != TRACKER_OK:
|
|
last_error = f"dht_parse_message failed: {rc}"
|
|
continue
|
|
got_tx = bytes(msg.transaction[:msg.transaction_len])
|
|
if got_tx != tx or msg.type != DHT_MSG_RESPONSE:
|
|
continue
|
|
|
|
for i in range(int(msg.peer_count)):
|
|
ep = self._peer_endpoint(msg.peers[i])
|
|
if ep:
|
|
peers.add(ep)
|
|
for i in range(int(msg.node_count)):
|
|
ep = self._node_endpoint(msg.nodes[i])
|
|
if ep and ep not in seen_nodes and ep not in queue:
|
|
queue.append(ep)
|
|
discovered += 1
|
|
break
|
|
|
|
return DHTResult(
|
|
peers=sorted(peers),
|
|
nodes_queried=queried,
|
|
nodes_discovered=discovered,
|
|
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
|
error="" if peers else last_error,
|
|
)
|