Skip to content

Commit

Permalink
Initial Import
Browse files Browse the repository at this point in the history
  • Loading branch information
lackstein committed May 2, 2014
0 parents commit 2cddf9e
Show file tree
Hide file tree
Showing 7 changed files with 373 additions and 0 deletions.
Binary file added .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions README
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is a reimplementation of the original RTracker code that should work with Gazelle-based BitTorrent sites from 2008. The code has not been updated since, and may not be compatible with the current release of Gazelle.
158 changes: 158 additions & 0 deletions cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
##################################################################
## Ruby BitTorrent Tracker ##
## ##
## ##
## Copyright 2008 Noah ##
## Released under the Creative Commons Attribution License ##
##################################################################

# Require RubyGems
require 'rubygems'
# Require the memcache gem
require 'memcache'

class Cache
attr_reader :cache, :mysql, :user, :torrent

def initialize(options = {})
raise CacheError, "You must provide a host and a namespace!" if options[:host].nil? or options[:namespace].nil?
@cache = MemCache::new options[:host], :namespace => options[:namespace]

unless options[:load_subclasses] == false # So we can get access to the cache without having it instantiate user and torrent objects
raise CacheError, "You must provide a MySQL object!" unless options[:mysql].is_a?(Mysql)
@mysql = options[:mysql]
raise CacheError, "You must provide an info_hash!" if options[:info_hash].nil?
@torrent = Torrent.new(cache, mysql, options[:info_hash])
raise CacheError, "You must provide a passkey and a peer_id!" if options[:passkey].nil? or options[:peer_id].nil?
@user = User.new(cache, mysql, options[:passkey], options[:peer_id], @torrent)

@torrent.user = @user # Let out classes know about each other
end
end

def stats
{ :user => cache.get("user_cache_hits"), :torrent => cache.get("torrent_cache_hits") }
end

class User
attr_reader :cache, :mysql, :passkey, :peer, :user
attr_accessor :torrent

def initialize(cache, mysql, passkey, peer_id, torrent)
@cache, @mysql, @passkey, @peer_id, @torrent = cache, mysql, passkey, peer_id, torrent
self.find # Either pull the user out of the cache, or reload! it from mysql
end

def find
@user = cache.get("user_#{passkey}") # Try to pull the user out of the cache
cache.incr("user_cache_hits", 1) unless user.nil? # We've got a hit!
self.reload! if user.nil? # Try and find us in the database

# Find the torrent's peerlist entry which belongs to us
@peer = torrent.peers.select { |peer| peer['PeerID'] == @peer_id }.first
end

def reload!
@user = mysql.query( "SELECT um.ID, um.Enabled, um.can_leech, p.Level FROM users_main AS um LEFT JOIN permissions AS p ON um.PermissionID=p.ID WHERE torrent_pass = '#{passkey}'" ).fetch_hash
cache.set("user_#{passkey}", user.merge({ 'Cached' => true }), 60*60*1) # Save the result to the cache, and leave a note saying it was cached
end

def update(uploaded, downloaded, left)
uploaded, downloaded = uploaded.to_i - peer['Uploaded'].to_i, downloaded.to_i - peer['Downloaded'].to_i # Find out exactly how much they uploaded since their last announce
if uploaded > 0 || downloaded > 0 # Only update if they've uploaded / downloaded something
# Set download = 0 if the torrent is freeleech
downloaded = 0 if torrent.freeleech?

# Add the user to the update queue
update_list = cache.get("user_update_list") || [] # Set it to a blank array if it's nil
update_list << { :id => user['ID'], :uploaded => uploaded, :downloaded => downloaded, :left => left, :peer => peer['ID'] }
cache.set("user_update_list", update_list)
end
end

def exists?
!user.nil? && user['Enabled'] == '1'
end

def can_leech?
user['can_leech'] == '1'
end

def cached?
!!user['Cached']
end

def [](column)
user[column]
end
end

class Torrent
attr_reader :cache, :mysql, :hash, :torrent, :peers
attr_accessor :user

def initialize(cache, mysql, info_hash)
@cache, @mysql, @hash = cache, mysql, info_hash
self.find
end

def find
# Find the torrent
@torrent = @cache.get("torrent_#{hash}")
@cache.incr("torrent_cache_hits", 1) unless @torrent.nil?
self.reload! if @torrent.nil?

# Find the peers
@peers = @cache.get("torrent_#{hash}_peers")
@cache.incr("peers_cache_hits", 1) unless @peers.nil?
self.reload_peers! if @peers.nil?
end

def reload!
@torrent = @mysql.query( "SELECT t.ID, t.FreeTorrent, t.Seeders, t.Leechers, t.Snatched, g.Name FROM torrents AS t LEFT JOIN torrents_group AS g ON t.GroupID=g.ID WHERE info_hash = '#{@hash}'" ).fetch_hash
@cache.set("torrent_#{hash}", @torrent.merge({ 'Cached' => true }), 60*60*1)
end

def reload_peers!
@peers = [] # MySQL is weird when returning lists of things, and memcache doesn't like that, so we're going to convert them to an array
@mysql.query( "SELECT p.ID, p.UserID, p.IP, p.Port, p.PeerID FROM tracker_peers AS p WHERE TorrentID = #{@torrent['ID']} ORDER BY RAND()" ).each_hash do |peer|
@peers << peer
end
@cache.set("torrent_#{hash}_peers", @peers, 60*60*1)
end

def new_peer(ip, port, left, peer_id)
@mysql.query( "INSERT INTO tracker_peers (UserID, TorrentID, IP, Port, Uploaded, Downloaded, tracker_peers.Left, PeerID) VALUES (#{user['ID']}, #{@torrent['ID']}, '#{ip}', '#{port}', 0, 0, #{left}, '#{peer_id}')" )
id = @mysql.insert_id
@peers = @cache.get("torrent_#{hash}_peers") || []
@peers << { 'ID' => id, 'UserID' => user['ID'], 'IP' => ip, 'Port' => port, 'PeerID' => peer_id }
@cache.set("torrent_#{hash}_peers", @peers, 60*60*1)
end

def delete_peer
@mysql.query( "DELETE FROM tracker_peers WHERE ID = #{user.peer['ID']}" )
@peers = @cache.get("torrent_#{hash}_peers") || []
@peers = @peers.reject { |peer| peer['ID'] == user.peer['ID'] }
@cache.set("torrent_#{hash}_peers", @peers, 60*60*1)
end

def exists?
!@torrent.nil?
end

def freeleech?
!!@torrent['FreeTorrent']
end

def cached?
!!@torrent['Cached']
end

def [](column)
@torrent[column]
end
end
end

# Error class
class CacheError < RuntimeError; end # Used to clearly identify errors
15 changes: 15 additions & 0 deletions config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
require 'sinatra'
require 'daemons'
#require 'schedule'

Sinatra::Application.default_options.merge!(
:run => false,
:env => :production,
:raise_errors => true,
:app_file => 'tracker.rb'
)


#Daemons.call { Schedule.new.run }
require 'tracker'
run Sinatra.application
13 changes: 13 additions & 0 deletions config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
:log_announces: false
:announce_int: 1800
:min_announce_int: 300
:whitelist_url: http://example.com/whitelist
:mysql:
:host: localhost
:user: user
:pass: pass
:database: database
:cache:
:host: localhost
:namespace: rtracker
45 changes: 45 additions & 0 deletions schedule.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
##################################################################
## Ruby BitTorrent Tracker ##
## ##
## ##
## Copyright 2008 Noah ##
## Released under the Creative Commons Attribution License ##
##################################################################

# Require RubyGems
require 'rubygems'
# Require the mysql gem
require 'mysql'
# Require the memcache gem
require 'memcache'
# Require YAML to parse config files
require 'yaml'

@config = YAML::load( open('config.yml') )
@cache = MemCache::new(@config[:cache][:host], :namespace => @config[:cache][:namespace])
@mysql = Mysql::new($config[:mysql][:host], $config[:mysql][:user], $config[:mysql][:pass], $config[:mysql][:database])

class Schedule
def run
loop do
update_users
flush_inactive_peers
sleep @config[:user_update_int]
end
end

def update_users
update_list = @cache.get("user_update_list")
unless update_list.nil?
user_values = update_list.collect { |user| "(#{user[:id]}, #{user[:uploaded]}, #{user[:downloaded]})" }.join(',')
peer_values = update_list.collect { |user| "(#{user[:id]}, #{user[:uploaded]}, #{user[:downloaded]}, #{user[:left]})" }.join(',')
@mysql.query( "INSERT LOW PRIORITY INTO users_main (ID, Uploaded, Downloaded) VALUES #{user_values} ON DUPLICATE KEY UPDATE Uploaded=Uploaded+VALUES(Uploaded), Downloaded=Downloaded+VALUES(Downloaded)")
@mysql.query( "INSERT LOW PRIORITY INTO tracker_peers (ID, Uploaded, Downloaded, Left) VALUES #{peer_values} ON DUPLICATE KEY UPDATE Uploaded=Uploaded+VALUES(Uploaded), Downloaded=Downloaded+VALUES(Downloaded), Left=VALUES(Left), Time=NOW()")
@cache.set("users_update_list", [], 60*60*1)
end
end

def flush_inactive_peers
@mysql.query( "DELETE FROM tracker_peers WHERE Time < TIMESTAMP(NOW()-INTERVAL 2 HOUR)")
end
end
141 changes: 141 additions & 0 deletions tracker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
##################################################################
## Ruby BitTorrent Tracker ##
## ##
## ##
## Copyright 2008 Noah ##
## Released under the Creative Commons Attribution License ##
##################################################################

# Require RubyGems
require 'rubygems'
# Require the bencode gem from http://github.com/dasch/ruby-bencode-bindings/tree/master
require 'bencode'
# Require the mysql gem
require 'mysql'
# Require the memcache gem
require 'memcache'
# Require our cache-abstraction class
require 'cache'
# Require the sinatra gem
require 'sinatra'
# Require YAML to parse config files
require 'yaml'

configure do
# Load the config
$config = YAML::load( open('config.yml') )
# Connect to MySQL
$db = Mysql::new($config[:mysql][:host], $config[:mysql][:user], $config[:mysql][:pass], $config[:mysql][:database])

whitelist = $db.query( "SELECT Peer_ID, Client FROM whitelist" )
$whitelist = Array.new
whitelist.each_hash { |client| $whitelist << { :regex => /#{client['Peer_ID']}/, :name => client['Client'] } } # Put a RegEx of each peerid into $whitelist
end

get '/:passkey/announce' do
begin
# Set instance variables for all the query parameters and make sure they exist
required_params = ['passkey', 'info_hash', 'peer_id', 'port', 'uploaded', 'downloaded', 'left']
optional_params = ['event', 'compact', 'no_peer_id', 'ip', 'numwant']
(required_params + optional_params).each do |param|
error "Bad Announce" if (params[param].nil? or params[param].empty?) and required_params.include?(param)
self.instance_variable_set("@" + param, escape(params[param]))
end
@event ||= 'none'
@ip ||= escape(@ip)
@numwant ||= 50

# Make sure client is whitelisted
whitelisted = $whitelist.map { |client| @peer_id =~ client[:regex] }.include? 0
error "Your client is banned. Go to #{$config[:whitelist_url]}" unless whitelisted

# Instantiate a cache object for this request
cache = Cache.new(:host => $config[:cache][:host], :namespace => $config[:cache][:namespace], :mysql => $db, :passkey => @passkey, :peer_id => @peer_id, :info_hash => @info_hash)

# Find our user
user = cache.user
error "Account not found" unless user.exists?
error "Leeching Disabled" unless user.can_leech?

# Find our torrent
torrent = cache.torrent
error "Torrent not found" unless torrent.exists?

# Find peers
peers = torrent.peers[0, @numwant.to_i]

# Log Announce
$db.query( "INSERT INTO tracker_announce_log (UserID, TorrentID, IP, Port, Event, Uploaded, Downloaded, tracker_announce_log.Left, PeerID, RequestURI, Time) VALUES (#{user['ID']}, #{torrent['ID']}, '#{@ip}', #{@port}, '#{@event}', #{@uploaded}, #{@downloaded}, #{@left}, '#{@peer_id}', '#{escape request.env['QUERY_STRING']}', NOW())" ) if $config[:log_announces]

# Generate Peerlist
if @compact == '1' # Compact Mode
peer_list = ''
peers.each do |peer| # Go through each peer
ip = peer['IP'].split('.').collect { |octet| octet.to_i }.pack('C*')
port = [peer['Port'].to_i].pack('n*')
peer_list << ip + port
end
else
peer_list = []
peers.each do |peer| # Go through each peer
peer_hash = { 'ip' => peer['IP'], 'port' => peer['Port'] }
peer_hash.update( { 'peer id' => peer['PeerID'] } ) unless @no_peer_id == 1
peer_list << peer_hash
end
end

@resp = { 'interval' => $config[:announce_int], 'min interval' => $config[:min_announce_int], 'peers' => peer_list }
# End peerlist generation

# Update database / cache
# Update values specific to each event
case @event
when 'started'
# Add the user to the torrents peerlist, then update the seeder / leecher count
torrent.new_peer(@ip, @port, @left, @peer_id)
$db.query( "UPDATE torrents SET #{@left.to_i > 0 ? 'Leechers = Leechers + 1' : 'Seeders = Seeders + 1'} WHERE ID = #{torrent['ID']}" )
when 'completed'
$db.query( "INSERT INTO tracker_snatches (UserID, TorrentID, IP, Port, Uploaded, Downloaded, PeerID) VALUES (#{user['ID']}, #{torrent['ID']}, '#{@ip}', #{@port}, #{@uploaded}, #{@downloaded}, '#{@peer_id}')" )
$db.query( "UPDATE torrents SET Seeders = Seeders + 1, Leechers = Leechers - 1, Snatched = Snatched + 1 WHERE ID = #{torrent['ID']}" )
when 'stopped'
# Update Seeder / Leecher count for torrent, and update snatched list with final upload / download counts, then delete the user from the torrents peerlist
$db.query( "UPDATE torrents AS t, tracker_snatches AS s SET #{@left.to_i > 0 ? 't.Leechers = t.Leechers - 1' : 't.Seeders = t.Seeders - 1'}, s.Uploaded = #{@uploaded}, s.Downloaded = #{@downloaded} WHERE t.ID = #{torrent['ID']} AND (s.UserID = #{user['ID']} AND s.TorrentID = #{torrent['ID']})" )
torrent.delete_peer
end

# Add user to the update queue
user.update(@uploaded, @downloaded, @left)

@resp.bencode
rescue TrackerError => e
e.message
end
end

get '/:passkey/scrape' do
begin
error "Bad Scrape" if params['info_hash'].nil? or params['info_hash'].empty?
cache = MemCache::new $config[:cache][:host], :namespace => $config[:cache][:namespace]
torrent = Cache::Torrent.new(cache, $db, params['info_hash'])

{ params['info_hash'] => { 'complete' => torrent['Seeders'], 'incomplete' => torrent['Leechers'], 'downloaded' => torrent['Snatched'], 'name' => torrent['Name'] } }.bencode
rescue TrackerError => e
e.message
end
end

get '/whitelist' do
$whitelist.collect { |client| client[:name] }.join('<br />')
end

helpers do
def error reason
raise TrackerError, { 'failure reason' => reason }.bencode
end
def escape string
Mysql::escape_string(string.to_s)
end
end

# Error class
class TrackerError < RuntimeError; end # Used to clearly identify errors

0 comments on commit 2cddf9e

Please sign in to comment.