From 2cddf9e6d5fb09acb90ba40fc1e950fdf391503e Mon Sep 17 00:00:00 2001 From: Noah Lackstein Date: Sat, 3 May 2014 00:19:03 +0100 Subject: [PATCH] Initial Import --- .DS_Store | Bin 0 -> 6148 bytes README | 1 + cache.rb | 158 ++++++++++++++++++++++++++++++++++++++++++++++++++++ config.ru | 15 +++++ config.yml | 13 +++++ schedule.rb | 45 +++++++++++++++ tracker.rb | 141 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 373 insertions(+) create mode 100644 .DS_Store create mode 100644 README create mode 100644 cache.rb create mode 100644 config.ru create mode 100644 config.yml create mode 100644 schedule.rb create mode 100644 tracker.rb diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..ee8cacc49d966bab590b8ba78e40f6a8490910e4 GIT binary patch literal 6148 zcmeHKOHKnZ41H-U=x5U%NNjQfByKRO%7Qgw57Y9g7-&`K0*S?3fg^AjF2N}{0q`7~ zeqm9B5JI-(y!>n@Z!(iHfY6!r_kcElI#saJVDX8_xaf*htY?!Ll8;mDVvM{$D0(Zi zz2h%3AkVIj9!{tin0tPCG0Zs@)1qs@+f1HVebnat1IHV*9pV~i7~njefQg)dH9Rzz zJlx?5#hN}gFv0{m3a+AePz*of^Js@7uJT1!{Jh0_3pcjkK5dF`?z@Uxu5+{$Ylr?{ z=VCQEYn=GKWSsHr4OxR9x^!^{oB?Ox2N~d4>M>PB=9P+5 k6IRky%(-$EZ&Q=7KT3s|8J3Fdq4n+a literal 0 HcmV?d00001 diff --git a/README b/README new file mode 100644 index 0000000..260ac8b --- /dev/null +++ b/README @@ -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. \ No newline at end of file diff --git a/cache.rb b/cache.rb new file mode 100644 index 0000000..f7b02fc --- /dev/null +++ b/cache.rb @@ -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 \ No newline at end of file diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..d472cd6 --- /dev/null +++ b/config.ru @@ -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 \ No newline at end of file diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..4e1eba6 --- /dev/null +++ b/config.yml @@ -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 \ No newline at end of file diff --git a/schedule.rb b/schedule.rb new file mode 100644 index 0000000..d074dde --- /dev/null +++ b/schedule.rb @@ -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 \ No newline at end of file diff --git a/tracker.rb b/tracker.rb new file mode 100644 index 0000000..7edd56c --- /dev/null +++ b/tracker.rb @@ -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('
') +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 \ No newline at end of file