Last post, we wrote a parser for bencoded torrent files. Now we need to connect to a tracker so we find out which peers can send us the file.
Before we jump into that though, let's review the general architecture of how to download a file through bit torrent.
If you already understand the general overview of BitTorrent or you don't care, skip right to the bit where we start to talk to trackers.
General Steps to Download via BitTorrent
To download a file through BitTorrent, you need to start with a .torrent file or a magnet link. For now we'll use a .torrent file directly. We'll add magnet link support as a separate feature later.
In any case, once you have the .torrent file, you decode the bencoding into something you can work with in your program (a dictionary in our case). The "announce" key lists the server that keeps track of the torrent stats, and the list of peers that are keeping the torrent alive. There is also an extension to the BitTorrent protocol which allows a torrent file to list backup trackers which can be used in case the primary tracker isn't available. Our code is going to assume the primary server is online for now. Retrying the backup servers will come later.
The meta spec talks about trackers as if they are an HTTP service, but you will notice that our parsed sintel.torrent lists the tracker as udp://tracker.leechers-paradise.org:6969
. This is because there is an extension to the BitTorrent protocol to add support for UDP trackers. I'm not sure how similar the UDP trackers are to the HTTP ones, but for this post I'm going to focus on the UDP protocol because that's what's in the torrent file. If HTTP trackers is something that's important to you, let me know and I'll consider expanding this post. Otherwise this will be a later feature.
To join the peer-to-peer network, you need to perform a "hello handshake" with the tracker and then announce that you exist. The tracker will respond to the announce request with some info about the torrent and a list of peers that you can connect with to download the file you want. Your client should also report uploaded/downloaded/left stats. I think that this bandwidth reporting happens over time too, since it would make sense to me that the tracker prioritizes well behaved peers. I'm also not certain what verification methods are in place to tell if you're actually telling the truth about your upload/download.
When you have a list of peers that have the file you want, you connect to them on the port they advertised and request a "piece" of the file that you want. The torrent file contains a list of all the necessary pieces that are required to make up the full file. Once you acquire all the pieces, you can assemble into the final file. At this point you will have successfully downloaded a file from BitTorrent.
Before we get that far, we still need to make our client speak to trackers and peers. And talking to trackers in the point of this post, so let's get to it.
Talking to Trackers
As I mentioned earlier, we're going to be focusing on the UDP tracker protocol since that's what's listed in my torrent file. In order to implement this feature, I'll be consulting the UDP tracker spec extension.
To start talking to a tracker, we need to open up a UDP socket to the tracker address. To do this in node.js, we need to use the UDP/Datagram module, and the messages are going to be in node.js Buffer format, so we'll need to use the Buffer module as well.
const dgram = require('dgram')
// Buffer is available globally by default, so we don't need this line
// const Buffer = require('buffer').Buffer
Then we need to read the tracker out of our parsed torrent file and parse the host/port out of it using the url
module from the node.js standard library.
const url = require('url')
let tracker = url.parse(torrent_data.announce)
let tracker_host = tracker.hostname
let tracker_port = tracker.port
And then open a UDP socket to the tracker.
let socket = dgram.createSocket('udp4')
Before we start announcing ourselves, we need to do a connection handshake with the tracker. This is necessary because we're using UDP instead of TCP. TCP has built-in verification that both IPs in the connection are correct. UDP doesn't have a similar verification system, so the UDP tracker protocol handles this at the application level.
To start the handshake, we first send a connection request packet to the tracker. The spec lists this as the correct format for a connect request:
Offset Size Name Value
0 64-bit integer protocol_id 0x41727101980 // tracker protocol ID
8 32-bit integer action 0 // connect
12 32-bit integer transaction_id
16
Once we send a connect request, the server will send us a packet back with a connection id that we can use in our future announce and scrape requests. If we weren't lying about our IP then we'll receive the response.
Weird binary tricks
This section is info about how to pack binary data into packets in Javascript. It's not necessary that you understand the details here, so only read this section if you find it interesting.
In Javascript, you cannot store a 64 number in a single Number type variable if you want to use bitwise operators on it. The most you can safely store is 32 bits. You'll notice that the constant "protocol_id" at the beginning of the connection message is a 64 bit integer, so we're going to need to use 2 32-bit numbers to store the whole number correctly.
Disclaimer: This is the part where I had to check someone else's implementation to verify how to correctly set bits in our connect message (thanks @superafroman!). I ended up learning a lot about binary encoding here.
In order to store 0x41727101980
in 2 32-bit numbers, we can just chop off the last 32 bits (0x27101980
, 4 bits per hex digit) and store that in one number, and take the remaining 0x417
in another number. Then we can write 0x417
to our connection message in the first 32 bits, and 0x27101980
into the next 32 bits. This results in the same value as if we had written a 64-bit number in one go.
This number chopping works because it's encoded in hexidecimal. 1 digit neatly contains a group of 4 bits, so adding or removing digits never changes the binary encoding of the surrounding digits. 0xf
is 1111
, 0xf0
is 11110000
, and 0xf8
is 11111000
. You'll notice that the 1111
at the beginning, which corresponds to the first f
, never changes. The same is not true of base 10 decimal. You cannot take 123456
and break it apart into 123
and 456
and expect to get the same number if you concatenate their binary representations.
Constructing the connect message
So let's actually put this connection request together. First we need a message buffer that is 16 bytes.
let connect_message = Buffer.alloc(16)
Then we need to set the "protocol_id" on the connect message buffer. Accessing an array index on a nodejs buffer will allow you to read/write 1 byte (8 bits). So in order to write our first 32 bit number into the buffer, we need to write 4 8-bit chunks.
connect_message[0] = 0x417 >> 24
connect_message[1] = (0x417 >> 16) & 0xff
connect_message[2] = (0x417 >> 8) & 0xff
connect_message[3] = 0x417 & 0xff
0x417
is short for the full 32 bits of 0x00000417
in this code. On the first line, we drop the right-most 24 bits of the number, which leaves us with just the 8 bits that will fit in this index of the buffer (the first 0x00
in this case).
Next we drop the rightmost 16 bits, which leaves us with 16 bits. In order to drop the higher bits that we already wrote into index 0, we use the bitwise AND operator to keep the rightmost 8 bits and discard the rest (so we're writing 0x00
here too).
Then we repeat the same trick, but only discarding the rightmost 8 bits (so we write 0x04
here).
And in the last write (index 3) we don't bit shift at all since it's the rightmost 8 bits that we want to write while discarding the 24 leftmost bits (0x17
here).
Once we've written 0x417
to the buffer, we do the same thing again for 0x27101980
.
connect_message[4] = 0x27101980 >> 24
connect_message[5] = (0x27101980 >> 16) & 0xff
connect_message[6] = (0x27101980 >> 8) & 0xff
connect_message[7] = 0x27101980 & 0xff
We write 0x27
into index 4, 0x10
into 5, 0x19
into 6, and 0x80
into 7.
And now we have the 64 bit protocol ID written into the first 16 bytes of the connect message.
This code is a bit verbose, so let's simplify it. A function that writes a 32 bit int into a 4 byte buffer would simplify the code.
function int_buffer(int) {
let b = Buffer.alloc(4)
b[0] = int >> 24
b[1] = (int >> 16) & 0xff
b[2] = (int >> 8) & 0xff
b[3] = int & 0xff
return b
}
Next, we need to write a 32-bit "action" field into the message. Since this is a 0
, we can just leave it blank, which means that we won't touch bytes 8 - 11 in connect_message.
The last thing we need for the connect message is a transaction ID. This just needs to be a random 32-bit value which the server will send back to us with our connection_id.
let transaction_id = Math.round(Math.random()*0xffffffff)
// in this post this value is 0x2790757a
So at last we can construct our connect message.
let connect_message = Buffer.concat([
int_buffer(0x417), // first half of protocol id
int_buffer(0x27101980), // second half of protocol id
int_buffer(0), // action
int_buffer(transaction_id),
])
Sending a message to the tracker
And now we can send our connect message through the socket we opened earlier.
function on_send(err) {
if (err) {
fatal(err)
}
console.log('Connect sent: '+connect_message.toString('hex'))
}
socket.send(connect_message, tracker_host, tracker_port, on_send)
function fatal(err) {
console.error(err)
process.exit(1)
}
And we need to listen for a response from the server.
socket.on('message', function(response) {
console.log('Incoming: '+response.toString('hex'))
})
When I ran this, I got the following:
Connect sent: 0000041727101980000000002790757a
Incoming: 000000002790757a49b2db7d0d692218
The spec states that a connect response will be in the following format:
Offset Size Name Value
0 32-bit integer action 0 // connect
4 32-bit integer transaction_id
8 64-bit integer connection_id
16
Filled with the data I got in the response:
Offset Size Name Value
0 32-bit integer action 00 00 00 00 // connect
4 32-bit integer transaction_id 27 90 75 7a // same as we sent
8 64-bit integer connection_id 49 b2 db 7d // our new id
16 0d 69 22 18 // our new id
So let's parse our connect response into a dictionary.
let transaction_id = response.slice(4, 8)
let connection_id = response.slice(8, 16)
And there we have it! Our connection id is 0x49b2db7d0d692218
. We can now freely talk to the tracker.
Announce message
Next, we need to announce to the tracker that we want a file. The UDP spec lists this as the format for an announce request.
Offset Size Name Value
0 64-bit integer connection_id
8 32-bit integer action 1 // announce
12 32-bit integer transaction_id
16 20-byte string info_hash
36 20-byte string peer_id
56 64-bit integer downloaded
64 64-bit integer left
72 64-bit integer uploaded
80 32-bit integer event 0
84 32-bit integer IP address 0 // default
88 32-bit integer key
92 32-bit integer num_want -1 // default
96 16-bit integer port
98
Some values that we need to fill here are connection_id, transaction_id (both of which we already have), info_hash, peer_id, downloaded, left (how much is left in the file we want), uploaded, event, IP address, key, num_want, and port.
The bittorrent spec says that the info_hash should be the 20 byte sha1 hash of the bencoded "info" field in the torrent file.
const crypto = require('crypto')
let info_hash = crypto.createHash('sha1')
info_hash.update(bencode(torrent_data.info))
info_hash = info_hash.digest()
And the peer ID is a random string of 20 characters.
let peer_id = crypto.randomFillSync(Buffer.alloc(20))
Downloaded, left, and uploaded should all be a 64 bit number. Node.js has BigInt
available since version 10.4.0
, so we'll use this for all three. Left is the sum of all the files in the torrent since we haven't downloaded anything yet.
let downloaded = BigInt(0)
let uploaded = BigInt(0)
let left = BigInt(0)
for (let file of torrent_data.info.files) {
left += BigInt(file.length)
}
And we'll also need a new helper method to convert bigints into buffers. I'm not going to explain too much how this works, but it's basically the same as the int_buffer()
version except in a for loop.
function bigint_buffer(value) {
let b = Buffer.alloc(64)
for (let i = 0; i < 8; i++) {
b[i] = value >> (BigInt(64 - (i*8+1))) & BigInt(0xff)
}
}
Event is either 0 for none, 1 for completed, 2 for started, and 3 for stopped. Currently we haven't even connected to peers yet, so we'll leave it as 0.
let event = 0
We're not going to set IP address since the server already knows who we are based on the initial handshake.
let ip_address = 0
The UDP spec is a bit vague about the purpose of the key field. It does link to a similar spec page which clarifies slightly by stating that key is "A unique key that is randomized by the client.". I'm assuming that this key should be regenerated for each announce request, but I don't know that for sure.
let key = crypto.randomFillSync(Buffer.alloc(32))
The original UDP spec is similarly quiet about "num_want", but the linked spec clarifies this as the max number of peers that we want in our reply. We'll set this to -1 to let the tracker decide.
let num_want = -1
And finally the port field is the port that we're listening on so other peers can talk to us. Since we haven't implemented this functionality, we'll fib a little bit and say 6881. Other peers may try to connect to us, but since we're not listening they'll just move on and find someone who does. This behaviour will probably cause trackers to dislike you, and is very bad P2P citizenship, so do not run your program like this outside of testing.
let port = 6881
Now that we have all the fields sorted out, we have enough info to create our announcement packet:
let announce_packet = Buffer.concat([
connect_response.connection_id,
int_buffer(0x1), // 'announce' action
connect_response.transaction_id,
info_hash,
peer_id,
bigint_buffer(downloaded),
bigint_buffer(left),
bigint_buffer(uploaded),
int_buffer(event), // event
int_buffer(ip_address), // IP address
key,
int_buffer(num_want),
int_buffer(port),
])
And then we can send it after we get the connection response from the tracker. We'll only send the announce packet if the message we just got from the server was a "connect" response.
socket.on('message', function(message) {
console.log('Incoming: '+message.toString('hex'))
if (message.readInt32BE(0)==0x0) {
console.log('Processing connect message')
let transaction_id = message.slice(4, 8)
let connection_id = message.slice(8, 16)
let info_hash = crypto.createHash('sha1')
info_hash.update(bencoding.encode(torrent_data.info))
info_hash = info_hash.digest()
let peer_id = crypto.randomFillSync(Buffer.alloc(20))
let downloaded = BigInt(0)
let uploaded = BigInt(0)
let left = BigInt(0)
for (let file of torrent_data.info.files) {
left += BigInt(file.length)
}
let event = 0
let ip_address = 0
let key = crypto.randomFillSync(Buffer.alloc(32))
let num_want = -1
let port = 6881
let announce_packet = Buffer.concat([
connection_id,
int_buffer(0x1), // 'announce' action
transaction_id,
info_hash,
peer_id,
bigint_buffer(downloaded),
bigint_buffer(left),
bigint_buffer(uploaded),
int_buffer(event), // event
int_buffer(ip_address), // IP address
key,
int_buffer(num_want),
int_buffer(port),
])
socket.send(announce_packet, tracker.port, tracker.hostname, function(err) {
if (err) {
fatal(err)
}
console.log('Announce sent: '+announce_packet.toString('hex'))
})
} else {
console.log("I don't know what to do with this message, exiting")
process.exit(1)
}
})
And now we see...
Announce sent: 832946ad4fef0b2d00000001b36ab31d79409b5674dc97a6257eb5fd8ca7a1afbf1df1139e40160c7134eb794241b7867a24ff8488ed21e2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007b4ff770000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000572b39b6695d7133a7a1fb8ae07e0b7d368193c1edfd0ab58b89c7185cbfca5bffffffff00001ae1
Incoming: 00000001b36ab31d000006ee0000000000000001
I don't know what to do with this message, exiting
The code for this post is available for reference. If you have any questions or you find a problem in the post, let me know.