UDP has no OS-level listen/accept, so accepted connections share the listening UTPSocket's single bound Socket. The listener runs a demux loop keyed by remote endpoint: an ST_SYN from an unrecognized endpoint creates a new accepted UTPSocket (sharing the socket for sends, fed incoming packets via HandleIncomingPacket instead of ever calling BeginReceiveFrom itself) and completes the handshake by acking the SYN; anything else routes to the matching existing connection. Extracted the per-datagram header/ack/reorder handling shared by both the initiating and accepting roles into ProcessIncomingPacket. Two real, substantive bugs surfaced while building this out (both fixed, both covered by regression tests using two real UTPSocket/PeerWireConnection instances rather than scripted fakes): - AckNumber off-by-one on the initiator side: it derived its "next expected" baseline as (peer's reported seq_nr - 1), but the peer's actual first data send increments from that same seq_nr, landing one higher. This was never caught because earlier tests hand-crafted matching sequence numbers; a real acceptor's first data send exposed it immediately. Fixed by dropping the "-1" (and updated the accept path to seed AckNumber from the SYN's seq_nr directly, consistently). - SenduTPData self-armed an extra BeginReceive after every send (a leftover from before Receive()/consumer-driven receiving existed). With a consumer also driving its own receive loop (PeerWireConnection, the real usage pattern), these compete for incoming datagrams — a reply meant for the consumer's receive loop could be silently consumed by one of these no-op internal ones instead. This explains why no real peer has ever completed a uTP handshake in this project's testing, not just the missing passive-open. Removed entirely; the OS still buffers a datagram with no receive pending, so nothing is lost. - Also fixed: disposing a UTPSocket with a receive outstanding threw an unhandled SocketException/ObjectDisposedException on the callback thread and crashed the process; now caught and treated as "connection going away, nothing to deliver." Tested: 144 tests passing, including 4 new real end-to-end passive-open tests (bidirectional data transfer, two simultaneous clients demuxed correctly) and updated sequence-number expectations in the existing Receive() tests. Live-verified beyond the unit suite with a full PeerWireListener<UTPSocket> + PeerWireConnection<UTPSocket> round trip (real accept, bidirectional BitTorrent handshake, piece transfer) and a Demo run against real DHT/tracker peers (no exceptions). Real-world interop with foreign uTP implementations remains unverified — no real internet peer has completed a handshake — but that's now clearly scoped to wire-format compatibility, not bugs in this library's own connection handling, which is proven correct end-to-end.
16 KiB
bzTorrent
A .NET Standard 2.0 library for building BitTorrent clients and tools. Handles the protocol plumbing — tracker communication, peer wire connections, metadata parsing, peer discovery, and DHT — so you can focus on what your application does with torrents rather than the protocol details.
Installation
dotnet add package bzTorrent
Compatible with .NET Standard 2.0 and above (.NET 5/6/7/8/9, .NET Framework 4.6.1+, Xamarin, Unity, etc.).
Quick start
using bzTorrent;
using bzTorrent.Data;
using bzTorrent.IO;
// Load a .torrent file
var metadata = Metadata.FromFile("ubuntu.torrent");
// Or parse a magnet link
var metadata = MagnetLink.ResolveToMetadata("magnet:?xt=urn:btih:...");
Console.WriteLine(metadata.Name); // "ubuntu-24.04-desktop-amd64.iso"
Console.WriteLine(metadata.HashString); // "DAFC8C076CA2F3ED..."
Console.WriteLine(metadata.PieceHashes.Count); // number of pieces
Console.WriteLine(metadata.PieceSize); // bytes per piece
foreach (var file in metadata.GetFileInfos())
Console.WriteLine($"{file.Filename} ({file.FileSize} bytes, starts at byte {file.FileStartByte})");
foreach (var tracker in metadata.AnnounceList)
Console.WriteLine(tracker);
Finding peers
Trackers (HTTP and UDP)
using bzTorrent;
// HTTP tracker
ITrackerClient tracker = new HTTPTrackerClient(timeoutSeconds: 10);
// UDP tracker
ITrackerClient tracker = new UDPTrackerClient(timeoutSeconds: 10);
var info = tracker.Announce(
trackerUrl: "udp://tracker.opentrackr.org:1337/announce",
infoHash: metadata.HashString,
peerId: "-MY0001-123456789012",
downloaded: 0,
left: metadata.PieceHashes.Count * metadata.PieceSize,
uploaded: 0,
ipAddress: 0,
key: 0,
numWant: 200,
port: 6881,
compact: 0);
Console.WriteLine($"{info.Seeders} seeders, {info.Leechers} leechers");
foreach (var peer in info.Peers)
Console.WriteLine(peer); // IPEndPoint — IPv4 or IPv6
// Scrape (stats only, no peer list)
var stats = tracker.Scrape(trackerUrl, metadata.HashString);
Console.WriteLine($"{stats.Seeders} seeders, {stats.Leechers} leechers, {stats.Downloaded} completed");
IPv6 peers (BEP-7) are included automatically: HTTPTrackerClient merges the peers and peers6 fields from the tracker's response, and UDPTrackerClient returns whichever family the announce request itself went out on (IPv6 if the tracker hostname resolves to an AAAA record).
DHT (trackerless)
using bzTorrent.DHT;
var dht = new DHTClient();
dht.PeerFound += endpoint => Console.WriteLine($"DHT found peer: {endpoint}");
dht.Start();
// Bootstrap from the standard DHT bootstrap nodes, then search
var bootstrapNodes = new[]
{
new IPEndPoint(Dns.GetHostAddresses("router.bittorrent.com").First(), 6881),
new IPEndPoint(Dns.GetHostAddresses("router.utorrent.com").First(), 6881),
new IPEndPoint(Dns.GetHostAddresses("dht.transmissionbt.com").First(), 6881),
};
await dht.BootstrapAsync(bootstrapNodes);
dht.StartSearch(metadata.Hash); // searches continuously, re-querying every 5 minutes
// When done
dht.Dispose();
DHTClient uses a single dual-stack socket (BEP-32): bootstrap nodes, queries, and discovered peers can be IPv4 or IPv6 endpoints interchangeably — PeerFound fires with whichever family a peer used.
Local peer discovery (LPD / BEP-14)
using bzTorrent;
using bzTorrent.IO;
var lpd = new LocalPeerDiscovery<UDPSocket>();
lpd.NewPeer += (address, port, infoHash) =>
Console.WriteLine($"Local peer: {address}:{port}");
lpd.Open();
Connecting to a peer
using bzTorrent;
using bzTorrent.IO;
var connection = new PeerWireConnection<TCPSocket> { Timeout = 5 };
var client = new PeerWireClient(connection) { KeepConnectionAlive = true };
// Wire up events
client.HandshakeComplete += (pwc) => pwc.SendInterested();
client.BitField += (pwc, length, bitField) => pwc.SendInterested();
client.UnChoke += (pwc) => { /* now we can request pieces */ };
client.Choke += (pwc) => { /* stop sending requests */ };
client.Have += (pwc, pieceIndex) => { /* peer has this piece */ };
client.Piece += (pwc, index, offset, data) =>
{
// write data to disk at (index * pieceSize + offset)
};
client.DroppedConnection += (pwc) => Console.WriteLine("Disconnected");
// Connect and handshake
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId);
// Drive the connection — call in a loop until it returns false
while (client.Process())
{
if (!client.ReceivedHandshake) continue;
// Request a piece block (pieceIndex, byteOffset, byteLength)
client.SendRequest(pieceIndex, offset, blockSize);
}
Requesting a piece in 16 KB blocks
BitTorrent pieces are typically 256 KB and must be requested in blocks of up to 16 KB:
var blockSize = 16 * 1024;
var pieceSize = metadata.PieceSize; // or smaller for the last piece
var blocks = (int)Math.Ceiling(pieceSize / (double)blockSize);
for (int b = 0; b < blocks; b++)
{
var offset = b * blockSize;
var length = (int)Math.Min(pieceSize - offset, blockSize);
client.SendRequest((uint)pieceIndex, (uint)offset, (uint)length);
}
uTP transport (BEP-29)
UTPSocket implements the Micro Transport Protocol (BEP-29) as a drop-in alternative to TCPSocket for PeerWireConnection<T> / PeerWireListener<T> — useful when you want BitTorrent traffic to back off under contention rather than crowding out other traffic on the link. It includes LEDBAT-based congestion control: the send window grows and shrinks based on measured one-way delay to the peer, per the algorithm in bzTorrent/Docs/utp-protocol.md.
using bzTorrent;
using bzTorrent.IO;
var connection = new PeerWireConnection<UTPSocket> { Timeout = 5 };
var client = new PeerWireClient(connection) { KeepConnectionAlive = true };
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId);
Note:
UTPSocketis experimental against real-world/foreign uTP implementations (wire compatibility with e.g. libutp-based clients hasn't been verified — no real internet peer has been observed completing a uTP handshake with this library yet). It's internally self-consistent and tested end-to-end, though: retransmission (RTO-based, with Karn's algorithm and exponential backoff) and out-of-order reassembly work — seeUTPRetransmissionTracker/UTPReorderBuffer—Receive()is a real blocking byte-stream read so MSE/PE (below) works over uTP too, and passive-open (Listen/Accept) works: twoUTPSockets (orPeerWireListener/PeerWireConnectionbuilt on them) can complete a full handshake and exchange data bidirectionally over real UDP. PreferTCPSocketfor production use until real-world interop is verified.
Message Stream Encryption (MSE/PE)
Obfuscates the handshake and subsequent traffic (RC4) so simple deep packet inspection can't identify it as BitTorrent. Set EncryptionMode on the PeerWireConnection before connecting/handshaking:
using bzTorrent.IO;
var connection = new PeerWireConnection<TCPSocket>
{
Timeout = 5,
// PlainText — never attempt MSE (default)
// PreferEncryption — attempt MSE, fall back to plaintext if the peer won't encrypt
// RequireEncryption — attempt MSE, refuse the connection if it can't be encrypted
EncryptionMode = PeerEncryptionMode.PreferEncryption
};
var client = new PeerWireClient(connection);
client.Connect(peerEndPoint);
client.Handshake(metadata.HashString, peerId); // runs the MSE key exchange first, if enabled
// Check whether the negotiated payload ended up encrypted
client.Piece += (pwc, index, offset, data) =>
Console.WriteLine(pwc.IsEncrypted ? "encrypted piece" : "plaintext piece");
PeerEncryptionOptions (on connection.EncryptionOptions) controls the negotiation details:
connection.EncryptionOptions.MaxPaddingBytes = 512; // spec default
AddKnownInfoHash is required to accept encrypted incoming connections, since an incoming MSE handshake hides the infohash until matched against a known set. PeerWireConnection<T>.Accept() propagates EncryptionMode and EncryptionOptions (including known infohashes) to each accepted connection automatically:
var listener = new PeerWireConnection<TCPSocket> { EncryptionMode = PeerEncryptionMode.PreferEncryption };
listener.EncryptionOptions.AddKnownInfoHash(metadata.HashString);
listener.Listen(new IPEndPoint(IPAddress.Any, 6881));
var accepted = listener.Accept(); // inherits EncryptionMode + known infohashes
Note: the higher-level
PeerWireListener<T>convenience class (see Listening for incoming connections) does not currently useAccept()internally, so encryption settings aren't propagated to connections it hands you — use the pattern above directly if you need encrypted incoming connections today.
Protocol extensions
Extensions plug into PeerWireClient and handle the details of their respective BEPs automatically.
Fast extensions (BEP-6)
using bzTorrent.ProtocolExtensions;
var fast = new FastExtensions();
fast.HaveAll += (pwc) => pwc.SendInterested(); // seeder — has everything
fast.HaveNone += (pwc) => { }; // peer has nothing yet
fast.AllowedFast += (pwc, index) => { /* request this piece even when choked */ };
fast.SuggestPiece += (pwc, index) => { /* peer recommends this piece */ };
client.RegisterBTExtension(fast);
Extended protocol container (BEP-10)
BEP-10 is required to use PEX, metadata exchange, and tracker exchange. Register it once and add sub-extensions to it:
using bzTorrent.ProtocolExtensions;
var extProtocol = new ExtendedProtocolExtensions();
// Add sub-extensions (see below)
extProtocol.RegisterProtocolExtension(client, pex);
extProtocol.RegisterProtocolExtension(client, trackerExchange);
extProtocol.RegisterProtocolExtension(client, utMetadata);
client.RegisterBTExtension(extProtocol);
Peer exchange (BEP-11)
Peers share peer lists with each other. Wire up Added to grow your peer pool for free:
var pex = new UTPeerExchange();
pex.Added += (pwc, ext, endpoint, flags) => { /* add endpoint to your peer pool */ };
pex.Dropped += (pwc, ext, endpoint) => { /* peer left the swarm */ };
extProtocol.RegisterProtocolExtension(client, pex);
Metadata exchange / magnet links (BEP-9)
Fetch the full .torrent info dictionary from peers when you only have a magnet link:
var utMetadata = new UTMetadata();
utMetadata.MetaDataReceived += (pwc, ext, infoDict) =>
{
// infoDict is a BDict (bzBencode) containing the full info dictionary
// Load it into a Metadata object:
var metadata = new Metadata();
metadata.Load(magnetLink); // sets hash + trackers from the magnet link
metadata.Load(infoDict); // populates pieces, files, name
};
extProtocol.RegisterProtocolExtension(client, utMetadata);
Tracker exchange (BEP-28)
Peers share tracker URLs they know about:
var trackerExchange = new LTTrackerExchange();
trackerExchange.TrackerAdded += (pwc, ext, trackerUrl) =>
{
// announce to the new tracker
};
extProtocol.RegisterProtocolExtension(client, trackerExchange);
DHT port advertisement (BEP-5)
Peers advertise their DHT port during the handshake:
var dhtPort = new DHTPortExtension();
dhtPort.Port += (pwc, port) =>
{
// peer's DHT node is listening on port
};
client.RegisterBTExtension(dhtPort);
Holepunch (BEP-55)
Lets a peer (A) that can't connect directly to another peer (C) it learned about via PEX ask a peer it IS connected to (B, if also connected to C) to relay a NAT hole-punch. This only implements the wire format and events — deciding when to rendezvous, tracking who's connected to whom, and retrying the connection is left to the consumer, same as the other extensions:
var holepunch = new UTHolepunchExtension();
holepunch.Rendezvous += (pwc, ext, target) => { /* we're B: relay by SendConnect to target and to the requester */ };
holepunch.Connect += (pwc, ext, target) => { /* we've been told to (re)try connecting to target */ };
holepunch.Error += (pwc, ext, target, code) => { /* rendezvous failed, see UTHolepunchErrorCode */ };
extProtocol.RegisterProtocolExtension(client, holepunch);
Note: the payload byte layout is implemented to the best available understanding of BEP-55 — verify against the published BEP text before relying on it for interop with other clients, the same caveat as this library's other experimental pieces.
WebSeeds (BEP-19)
Fetch piece data over plain HTTP(S) from URLs the torrent publishes, instead of (or alongside) peers — useful when the swarm has few seeders:
using bzTorrent;
if (metadata.WebSeeds.Count > 0)
{
IWebSeedClient webSeed = new HTTPWebSeedClient(timeoutSeconds: 10);
// Same (pieceIndex, offset, length) coordinates as PeerWireClient.SendRequest.
var block = webSeed.GetPieceBlock(metadata.WebSeeds.First(), metadata, pieceIndex, offset, length);
WritePieceData(pieceIndex, offset, block);
}
GetPieceBlock maps the request onto whichever file(s) it spans and issues one ranged HTTP GET per file, per BEP-19: a single-file torrent's URL addresses the file directly, a multi-file torrent's URL is treated as a directory with the torrent name and file path appended.
Writing piece data to files
MetadataFileInfo.FileStartByte gives the byte offset of each file within the overall torrent data stream. Use this to map piece data to the correct file position:
void WritePieceData(IMetadata metadata, string outputDir, int pieceIndex, int offset, byte[] data)
{
var absoluteStart = (long)pieceIndex * metadata.PieceSize + offset;
var absoluteEnd = absoluteStart + data.Length;
foreach (var file in metadata.GetFileInfos())
{
var fileEnd = file.FileStartByte + file.FileSize;
if (absoluteEnd <= file.FileStartByte || absoluteStart >= fileEnd)
continue;
var fileOffset = Math.Max(absoluteStart - file.FileStartByte, 0);
var dataOffset = (int)Math.Max(file.FileStartByte - absoluteStart, 0);
var writeLength = (int)Math.Min(data.Length - dataOffset, file.FileSize - fileOffset);
var path = Path.GetFullPath(file.Filename, outputDir);
using var fs = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
fs.Seek(fileOffset, SeekOrigin.Begin);
fs.Write(data, dataOffset, writeLength);
}
}
Listening for incoming connections
using bzTorrent;
using bzTorrent.IO;
var listener = new PeerWireListener<TCPSocket>(port: 6881);
listener.NewPeer += (client) =>
{
// client is a PeerWireClient — wire up events and call Process() as above
};
listener.Start();
Namespaces
| Namespace | Contents |
|---|---|
bzTorrent |
PeerWireClient, PeerWireListener, LocalPeerDiscovery, tracker clients, IWebSeedClient/HTTPWebSeedClient |
bzTorrent.Data |
Metadata, MagnetLink, MetadataFileInfo, MetadataPieceInfo |
bzTorrent.DHT |
DHTClient, DHTNode, DHTRoutingTable |
bzTorrent.IO |
PeerWireConnection, TCPSocket, UDPSocket, UTPSocket |
bzTorrent.ProtocolExtensions |
FastExtensions, ExtendedProtocolExtensions, UTPeerExchange, UTMetadata, LTTrackerExchange, DHTPortExtension, UTHolepunchExtension |
Implemented BEPs
| BEP | Description |
|---|---|
| BEP-3 | BitTorrent protocol — peer wire, tracker HTTP announce |
| BEP-5 | DHT protocol |
| BEP-6 | Fast extensions |
| BEP-7 | IPv6 tracker extension |
| BEP-9 | Extension for peers to send metadata files (magnet link metadata fetch) |
| BEP-10 | Extension protocol |
| BEP-11 | Peer exchange (PEX) |
| BEP-14 | Local service discovery |
| BEP-15 | UDP tracker protocol |
| BEP-19 | WebSeed — HTTP/FTP seeding |
| BEP-28 | Tracker exchange protocol |
| BEP-32 | IPv6 DHT extension |
| BEP-55 | Holepunch extension |
| — | Message Stream Encryption / Protocol Encryption (MSE/PE) — not a formal BEP, but widely implemented |
License
BSD 3-Clause. See LICENSE.