/* * Tracker - Keeps track of clients sharing a particular torrent MetaInfo. * Copyright (C) 2003 Mark J. Wielaard * * This file is part of Snark. * * This program is free software; you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software * Foundation; either version 2, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more * details. * * You should have received a copy of the GNU General Public License along with * this program; if not, write to the Free Software Foundation, Inc., 59 Temple * Place - Suite 330, Boston, MA 02111-1307, USA. * * Revised by Stephen L. Reed, Dec 22, 2009. * Reformatted, fixed Checkstyle, Findbugs and PMD violations, and substituted Log4J logger * for consistency with the Texai project. Converted to a private torrent protocol that * uses a single SSL socket. */ package org.texai.torrent; import java.io.UnsupportedEncodingException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import org.apache.commons.codec.DecoderException; import org.apache.commons.codec.net.URLCodec; import org.apache.log4j.Level; import org.apache.log4j.Logger; import org.jboss.netty.channel.Channel; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.QueryStringDecoder; import org.jboss.netty.handler.codec.http.websocketx.TextWebSocketFrame; import org.texai.network.netty.handler.HTTPRequestHandler; import org.texai.network.netty.handler.TexaiHTTPRequestHandler; import org.texai.network.netty.utils.NettyHTTPUtils; import org.texai.torrent.bencode.BEncoder; import org.texai.torrent.domainEntity.MetaInfo; import org.texai.util.HTTPUtils; import org.texai.util.NetworkUtils; import org.texai.util.TexaiException; /** Keeps track of clients sharing a particular torrent MetaInfo. * The tracker shares a multiplexed port with the local peer. */ public final class Tracker implements TexaiHTTPRequestHandler { /** the logger */ private static final Logger LOGGER = Logger.getLogger(Tracker.class); /** no event */ public static final String NO_EVENT = ""; /** started event */ public static final String STARTED_EVENT = "started"; /** completed event */ public static final String COMPLETED_EVENT = "completed"; /** stopped event */ public static final String STOPPED_EVENT = "stopped"; /** the 15 minute interval between tracker requests from the peer */ private static final int REQUEST_INTERVAL_SECONDS = 15 * 60; /** the peer expiration cushion factor */ private static final double PEER_EXPIRATION_CUSHION_FACTOR = 1.5d; /** the metainfo dictionary, URL-encoded torrent hash --> metainfo */ private final Map<String, MetaInfo> metaInfoDictionary = new HashMap<>(); /** the peers dictionary, URL-encoded torrent hash --> (map of tracked peer info --> tracked peer status) */ private final Map<String, Map<TrackedPeerInfo, TrackedPeerStatus>> trackedPeerInfosDictionary = new HashMap<>(); /** the completions dictionary, URL-encoded torrent hash --> number of download completions */ private final Map<String, Integer> completionsDictionary = new HashMap<>(); /** Creates a new Tracker instance. */ public Tracker() { HTTPRequestHandler.getInstance().register(this); } /** Handles the HTTP request. * * @param httpRequest the HTTP request * @param channel the channel * @return the indicator whether the HTTP request was handled */ @Override public boolean httpRequestReceived( final HttpRequest httpRequest, final Channel channel) { //Preconditions assert httpRequest != null : "httpRequest must not be null"; assert channel != null : "channel must not be null"; final String uriString = httpRequest.getUri(); LOGGER.info("received HTTP request: " + uriString); if (uriString.endsWith("/torrent-tracker/statistics")) { handleStatisticsRequest(httpRequest, channel); return true; } else if (uriString.endsWith("/torrent-tracker/scrape")) { handleScrapeRequest(httpRequest, channel); return true; } else if (uriString.contains("/torrent-tracker/announce")) { handleAnnounceRequest(httpRequest, channel); return true; } else { return false; } } /** Gets the torrent metainfo given the hash. * * @param urlEncodedInfoHash the URL-encoded info hash * @return the torrent metainfo given the hash */ public MetaInfo getMetaInfo(final String urlEncodedInfoHash) { //Preconditions assert urlEncodedInfoHash != null : "urlEncodedInfoHash must not be null"; assert !urlEncodedInfoHash.isEmpty() : "urlEncodedInfoHash must not be empty"; return metaInfoDictionary.get(urlEncodedInfoHash); } /** Adds a new info hash to the tracker. * * @param urlEncodedInfoHash the new URL-encoded info hash */ public void addInfoHash(final String urlEncodedInfoHash) { //Preconditions if (urlEncodedInfoHash == null || urlEncodedInfoHash.isEmpty()) { throw new IllegalArgumentException("invalid hash"); } synchronized (trackedPeerInfosDictionary) { trackedPeerInfosDictionary.put(urlEncodedInfoHash, new HashMap<>()); } } /** Adds a new metainfo to the tracker. * * @param metaInfo the new metainfo */ public void addMetainfo(final MetaInfo metaInfo) { //Preconditions if (metaInfo == null) { throw new IllegalArgumentException("invalid metaInfo"); } final String urlEncodedInfoHash = metaInfo.getURLEncodedInfoHash(); addInfoHash(urlEncodedInfoHash); metaInfoDictionary.put(urlEncodedInfoHash, metaInfo); } /** Handles the statistics request, which is not part of the BitTorrent protocol. It returns an * HTML statistics page. * * @param httpRequest the HTTP request * @param channel the channel */ public void handleStatisticsRequest( final HttpRequest httpRequest, final Channel channel) { assert httpRequest != null : "httpRequest must not be null"; assert channel != null : "channel must not be null"; final StringBuilder responseContent = new StringBuilder(); responseContent.append("<?xml version=\"1.0\" encoding=\"iso-8859-1\"?>\n"); responseContent.append("<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\n"); responseContent.append("<html lang=\"en-US\" xml:lang=\"en-US\" xmlns=\"http://www.w3.org/1999/xhtml\">\n"); responseContent.append(" <head>\n"); responseContent.append(" <title>Snark</title>\n"); responseContent.append(" </head>\n"); responseContent.append(" <body>\n"); responseContent.append(" <h2>Snark BitTorrent Tracker</h2>\n"); responseContent.append(" <table border=\"1\">\n"); responseContent.append(" <tr>\n"); responseContent.append(" <th>Torrent Hash</th>\n"); responseContent.append(" <th>Finished</th>\n"); responseContent.append(" <th>Seeders</th>\n"); responseContent.append(" <th>Leechers</th>\n"); responseContent.append(" </tr>\n"); Map<byte[], Map<String, Object>> filesDictionary = null; final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.getUri()); final Map<String, List<String>> parameterDictionary = queryStringDecoder.getParameters(); try { filesDictionary = gatherFilesDictionary(parameterDictionary); } catch (DecoderException ex) { throw new TexaiException(ex); } for (final Entry<byte[], Map<String, Object>> filesDictionaryEntry : filesDictionary.entrySet()) { responseContent.append(" <tr>\n"); responseContent.append(" <td>"); responseContent.append(new String((new URLCodec()).encode(filesDictionaryEntry.getKey()))); responseContent.append("</td>\n"); final Map<String, Object> peersDictionary = filesDictionaryEntry.getValue(); responseContent.append(" <td>"); responseContent.append(peersDictionary.get("downloaded")); responseContent.append("</td>\n"); responseContent.append(" <td>"); responseContent.append(peersDictionary.get("complete")); responseContent.append("</td>\n"); responseContent.append(" <td>"); responseContent.append(peersDictionary.get("incomplete")); responseContent.append("</td>\n"); responseContent.append(" </tr>\n"); } responseContent.append(" </table>\n"); responseContent.append(" </body>\n"); responseContent.append("</html>\n"); NettyHTTPUtils.writeHTMLResponse(httpRequest, responseContent, channel); } /** Handles the scrape request. * * @param httpRequest the HTTP request * @param channel the channel */ public void handleScrapeRequest( final HttpRequest httpRequest, final Channel channel) { assert httpRequest != null : "httpRequest must not be null"; assert channel != null : "channel must not be null"; final QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.getUri()); final Map<String, List<String>> parameterDictionary = queryStringDecoder.getParameters(); final InetSocketAddress remoteAddress = (InetSocketAddress) channel.getRemoteAddress(); LOGGER.warn("tracker scrape request: " + remoteAddress + " -> " + parameterDictionary); try { NettyHTTPUtils.writeBinaryResponse( httpRequest, BEncoder.bencode(gatherFilesDictionary(parameterDictionary)), channel, null); // sessionCookie } catch (DecoderException ex) { throw new TexaiException(ex); } } /** Handles the scrape request. * * @param parameterDictionary the parameter dictionary * @return the bencoded response * @throws DecoderException when a hash cannot be URL-decoded */ @SuppressWarnings("unchecked") public Map<byte[], Map<String, Object>> gatherFilesDictionary( final Map<String, List<String>> parameterDictionary) throws DecoderException { //Preconditions assert parameterDictionary != null : "parameterDictionary must not be null"; final Map<byte[], Map<String, Object>> filesDictionary = new HashMap<>(); final List<String> requestedURLEncodedInfoHashes = new ArrayList<>(); final Object infoHashValue = parameterDictionary.get("info_hash"); if (infoHashValue == null) { // when the optional parameter is absent, then reply with all tracked info hashes requestedURLEncodedInfoHashes.addAll(trackedPeerInfosDictionary.keySet()); } else if (infoHashValue instanceof List<?>) { requestedURLEncodedInfoHashes.addAll((List<String>) infoHashValue); } else { assert infoHashValue instanceof String; requestedURLEncodedInfoHashes.add((String) infoHashValue); } LOGGER.warn("reviewing tracked peers for statistics"); for (final String requestedURLEncodedInfoHash : requestedURLEncodedInfoHashes) { if (trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash)) { LOGGER.warn(" requested info hash: " + requestedURLEncodedInfoHash); final byte[] key; key = URLCodec.decodeUrl(requestedURLEncodedInfoHash.getBytes()); final Map<String, Object> peersDictionary = new HashMap<>(); // total number of times the tracker has registered a completion ("event=complete", i.e. a client finished downloading the torrent) final Integer nbrDownloaded = completionsDictionary.get(requestedURLEncodedInfoHash); LOGGER.warn(" nbrDownloaded: " + nbrDownloaded); if (nbrDownloaded == null) { peersDictionary.put("downloaded", 0); } else { peersDictionary.put("downloaded", nbrDownloaded); } int nbrLeechers = 0; int nbrSeeders = 0; synchronized (trackedPeerInfosDictionary) { final Map<TrackedPeerInfo, TrackedPeerStatus> trackedPeerInfosMap = trackedPeerInfosDictionary.get(requestedURLEncodedInfoHash); for (final TrackedPeerStatus trackedPeerStatus : trackedPeerInfosMap.values()) { LOGGER.warn(" trackedPeerInfo: " + trackedPeerStatus); switch (trackedPeerStatus.event) { case STARTED_EVENT: nbrLeechers++; break; case COMPLETED_EVENT: nbrSeeders++; break; default: assert false; break; } } } // number of non-seeder peers, aka "leechers" peersDictionary.put("incomplete", nbrLeechers); // number of peers with the entire file, i.e. seeders peersDictionary.put("complete", nbrSeeders); // (optional) the torrent's internal name, as specified by the "name" file in the info section of the metainfo final MetaInfo metaInfo = metaInfoDictionary.get(requestedURLEncodedInfoHash); if (metaInfo != null) { assert metaInfo.getName() != null; peersDictionary.put("name", metaInfo.getName()); } filesDictionary.put(key, peersDictionary); } } LOGGER.warn("filesDictionary: " + filesDictionary); return filesDictionary; } /** Returns whether this tracker is tracking the given URL-encoded info hash. * * @param requestedURLEncodedInfoHash the given URL-encoded info hash * @return whether this tracker is tracking the given URL-encoded info hash */ public boolean isTracking(final String requestedURLEncodedInfoHash) { //Preconditions assert requestedURLEncodedInfoHash != null : "requestedURLEncodedInfoHash must not be null"; synchronized (trackedPeerInfosDictionary) { return trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash); } } /** Returns whether this tracker has peers for the given URL-encoded info hash. * * @param requestedURLEncodedInfoHash the given URL-encoded info hash * @return whether this tracker is tracking the given URL-encoded info hash */ public boolean hasPeers(final String requestedURLEncodedInfoHash) { //Preconditions assert requestedURLEncodedInfoHash != null : "requestedURLEncodedInfoHash must not be null"; synchronized (trackedPeerInfosDictionary) { if (trackedPeerInfosDictionary.containsKey(requestedURLEncodedInfoHash)) { return !trackedPeerInfosDictionary.get(requestedURLEncodedInfoHash).isEmpty(); } else { return false; } } } /** Handles the announce request. * * @param httpRequest the HTTP request * @param channel the channel */ public void handleAnnounceRequest( final HttpRequest httpRequest, final Channel channel) { //Preconditions assert httpRequest != null : "httpRequest must not be null"; assert channel != null : "channel must not be null"; final URI uri; try { uri = new URI(httpRequest.getUri()); } catch (URISyntaxException ex) { throw new TexaiException(ex); } final Map<String, String> parameterDictionary = HTTPUtils.getQueryMap(uri.getRawQuery()); LOGGER.warn("tracker announce request: " + channel.getRemoteAddress() + " -> " + parameterDictionary); // info hash final String requestedUrlEncodedInfoHash = parameterDictionary.get("info_hash"); LOGGER.debug(" requestedUrlEncodedInfoHash: " + requestedUrlEncodedInfoHash); if (requestedUrlEncodedInfoHash == null) { failure(httpRequest, "No info_hash given", channel); return; } boolean found = false; LOGGER.debug("trackedPeerInfosDictionary: " + trackedPeerInfosDictionary); synchronized (trackedPeerInfosDictionary) { for (String urlEncodedInfoHash : trackedPeerInfosDictionary.keySet()) { if (urlEncodedInfoHash.equals(requestedUrlEncodedInfoHash)) { found = true; } } } if (!found) { failure(httpRequest, "Tracker doesn't handle given info_hash", channel); return; } // peer id byte[] peerIdBytes; final String peerIdValue = parameterDictionary.get("peer_id"); if (peerIdValue == null) { failure(httpRequest, "No peer_id given", channel); return; } try { peerIdBytes = (new URLCodec()).decode(peerIdValue.getBytes()); } catch (DecoderException ex) { failure(httpRequest, "cannot decode peer id value: " + peerIdValue, channel); return; } if (peerIdBytes.length != 20) { failure(httpRequest, "peer_id must be 20 bytes long", channel); return; } // port @SuppressWarnings("UnusedAssignment") int peerPort = 0; final String peerPortValue = parameterDictionary.get("port"); if (peerPortValue == null) { failure(httpRequest, "No port given", channel); return; } try { peerPort = Integer.parseInt(peerPortValue); } catch (NumberFormatException nfe) { failure(httpRequest, "port not a number: " + nfe, channel); return; } // ip final InetAddress inetAddress = ((InetSocketAddress) channel.getRemoteAddress()).getAddress(); InetAddress inetAddress1 = null; if (NetworkUtils.isPrivateNetworkAddress(inetAddress)) { // See http://wiki.theory.org/BitTorrentSpecification#Tracker_Response . // Handle the case where the peer and ourselves are both behind the same NAT router. The ip address from the // socket connection will be a private address which indicates the peer is on our private network behind the // internet-facing router. The optional ip parameter contains what the peer found as its local host address. final String peerSuppliedIPAddress = parameterDictionary.get("ip"); if (peerSuppliedIPAddress == null) { failure(httpRequest, "No ip address given", channel); return; } else { try { inetAddress1 = InetAddress.getByName(peerSuppliedIPAddress); } catch (UnknownHostException ex) { LOGGER.warn("invalid ip parameter supplied by peer: '" + peerSuppliedIPAddress + "'"); } } if (inetAddress1 == null) { inetAddress1 = inetAddress; } } else { inetAddress1 = inetAddress; } final TrackedPeerInfo trackedPeerInfo = new TrackedPeerInfo(peerIdBytes, inetAddress1, peerPort); // downloaded final int downloaded; final String downloaded_value = parameterDictionary.get("downloaded"); if (downloaded_value == null) { failure(httpRequest, "No downloaded given", channel); return; } try { downloaded = Integer.parseInt(downloaded_value); } catch (NumberFormatException nfe) { failure(httpRequest, "downloaded not a number: " + nfe, channel); return; } // event final Map<String, Object> responseDictionary = new HashMap<>(); final Map<TrackedPeerInfo, TrackedPeerStatus> trackedPeerInfosMap = trackedPeerInfosDictionary.get(requestedUrlEncodedInfoHash); final String event = parameterDictionary.get("event"); if (event == null || event.isEmpty()) { synchronized (trackedPeerInfosMap) { final TrackedPeerStatus trackedPeerStatus = trackedPeerInfosMap.get(trackedPeerInfo); if (trackedPeerStatus == null) { failure(httpRequest, "peer never started", channel); return; } LOGGER.warn("updating peer " + trackedPeerInfo + " expiration"); trackedPeerStatus.updatePeerExpirationMilliseconds(); } } else { if (event.equals(COMPLETED_EVENT)) { final TrackedPeerStatus trackedPeerStatus = trackedPeerInfosMap.get(trackedPeerInfo); if (trackedPeerStatus == null) { failure(httpRequest, "peer never started", channel); return; } trackedPeerStatus.event = COMPLETED_EVENT; if (downloaded == 0) { LOGGER.warn("seeding without download: " + trackedPeerInfo); } else { LOGGER.warn("seeding after completed download: " + trackedPeerInfo); synchronized (completionsDictionary) { final Integer nbrDownloaded = completionsDictionary.get(requestedUrlEncodedInfoHash); if (nbrDownloaded == null) { completionsDictionary.put(requestedUrlEncodedInfoHash, 1); } else { completionsDictionary.put(requestedUrlEncodedInfoHash, nbrDownloaded + 1); } } } } else { synchronized (trackedPeerInfosMap) { switch (event) { case STOPPED_EVENT: LOGGER.warn("removing stopped peer " + trackedPeerInfo); trackedPeerInfosMap.remove(trackedPeerInfo); break; case STARTED_EVENT: LOGGER.warn("adding new peer " + trackedPeerInfo); trackedPeerInfosMap.put(trackedPeerInfo, new TrackedPeerStatus(trackedPeerInfo, event)); break; default: failure(httpRequest, "invalid event", channel); return; } } } } // compose tracker response responseDictionary.put("interval", REQUEST_INTERVAL_SECONDS); final List<Map<String, Object>> peerList = new ArrayList<>(); final Iterator<TrackedPeerStatus> trackedPeerInfos_iter = trackedPeerInfosMap.values().iterator(); try { LOGGER.warn("client peer id " + new String(peerIdBytes, "US-ASCII")); } catch (UnsupportedEncodingException ex) { throw new TexaiException(ex); } LOGGER.warn("tracked peers ..."); while (trackedPeerInfos_iter.hasNext()) { final TrackedPeerStatus trackedPeerStatus = trackedPeerInfos_iter.next(); final TrackedPeerInfo trackedPeerInfo1 = trackedPeerStatus.trackedPeerInfo; LOGGER.warn(""); LOGGER.warn(" peer id " + trackedPeerInfo1.toIDString()); LOGGER.warn(" ip " + trackedPeerInfo1.getInetAddress().getHostAddress()); LOGGER.warn(" port " + trackedPeerInfo1.getPort()); if (trackedPeerStatus.peerExpirationMilliseconds < System.currentTimeMillis()) { LOGGER.warn("expiring peer " + trackedPeerInfo1); trackedPeerInfos_iter.remove(); } else if (trackedPeerInfo1.equals(trackedPeerInfo)) { LOGGER.warn("omitting self-peer from the peer list"); } else { final Map<String, Object> map = new HashMap<>(); map.put("peer id", trackedPeerInfo1.getPeerIdBytes()); map.put("ip", trackedPeerInfo1.getInetAddress().getHostAddress()); map.put("port", trackedPeerInfo1.getPort()); peerList.add(map); } } responseDictionary.put("peers", peerList); LOGGER.log(Level.DEBUG, "Tracker response: " + responseDictionary); NettyHTTPUtils.writeBinaryResponse( httpRequest, BEncoder.bencode(responseDictionary), channel, null); // sessionCookie } /** Returns a bencoded failure reason. * * @param httpRequest the HTTP request * @param reason the failure reason * @param channel the channel */ private static void failure( final HttpRequest httpRequest, final String reason, final Channel channel) { //Preconditions assert httpRequest != null : "httpRequest must not be null"; assert reason != null : "reason must not be null"; assert !reason.isEmpty() : "reason must not be empty"; assert channel != null : "channel must not be null"; final Map<String, String> map = new HashMap<>(); map.put("failure reason", reason); NettyHTTPUtils.writeBinaryResponse( httpRequest, BEncoder.bencode(map), channel, null); // sessionCookie } /** Handles a received text web socket frame. * * @param channel the channel handler context * @param textWebSocketFrame the text web socket frame * @return the indicator whether the web socket request was handled */ @Override public boolean textWebSocketFrameReceived( final Channel channel, final TextWebSocketFrame textWebSocketFrame) { throw new UnsupportedOperationException("Not supported yet."); } /** Contains tracked peer status. */ private static final class TrackedPeerStatus { /** the peer ID */ private final TrackedPeerInfo trackedPeerInfo; /** the peer expiration time in milliseconds */ private long peerExpirationMilliseconds; /** the last tracked event, either started, completed or stopped */ private String event; // NOPMD /** Constructs a new TrackedPeerStatus instance for a started peer. * * @param trackedPeerInfo the tracked peer information * @param event the last tracked event, either started, complete or stopped */ TrackedPeerStatus(final TrackedPeerInfo trackedPeerInfo, final String event) { //Preconditions assert trackedPeerInfo != null : "trackedPeerInfo must not be null"; assert event != null : "event must not be null"; assert event.equals(STARTED_EVENT) || event.equals(COMPLETED_EVENT) || event.equals(STOPPED_EVENT) : "event must be either started, completed or stopped"; this.trackedPeerInfo = trackedPeerInfo; this.event = event; updatePeerExpirationMilliseconds(); } /** Updates the peer expiration time in milliseconds. */ public void updatePeerExpirationMilliseconds() { peerExpirationMilliseconds = System.currentTimeMillis() + (long) (PEER_EXPIRATION_CUSHION_FACTOR * (REQUEST_INTERVAL_SECONDS * 1000L)); } /** Returns whether some other TrackedPeerStatus has the same peer ID as this one. * * @param obj the other object * @return whether some other object equals this one */ @Override public boolean equals(final Object obj) { if (obj instanceof TrackedPeerStatus) { final TrackedPeerStatus that = (TrackedPeerStatus) obj; return this.trackedPeerInfo.equals(that.trackedPeerInfo); } else { return false; } } /** Returns a hash code for this object. * * @return a hash code for this object */ @Override public int hashCode() { return trackedPeerInfo.hashCode(); } /** Returns a string representation of this object. * * @return a string representation of this object */ @Override public String toString() { final StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("[TrackedPeerStatus "); stringBuilder.append(trackedPeerInfo); stringBuilder.append(' '); stringBuilder.append(event); stringBuilder.append(']'); return stringBuilder.toString(); } } }