enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

752 lines
19 KiB
Go

package tracker
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/anacrolix/torrent/bencode"
"git.sovbit.dev/enki/torrentGateway/internal/config"
)
// Tracker represents a BitTorrent tracker instance
type Tracker struct {
peers map[string]map[string]*PeerInfo // infoHash -> peerID -> peer
mutex sync.RWMutex
config *config.TrackerConfig
gateway Gateway // Interface to gateway for WebSeed functionality
coordinator P2PCoordinator // Interface to P2P coordinator
startTime time.Time
}
// P2PCoordinator interface for tracker integration
type P2PCoordinator interface {
GetPeers(infoHash string) []CoordinatorPeerInfo
OnPeerConnect(infoHash string, peer CoordinatorPeerInfo)
AnnounceToExternalServices(infoHash string, port int) error
}
// CoordinatorPeerInfo represents peer info for coordination
type CoordinatorPeerInfo struct {
IP string
Port int
PeerID string
Source string
Quality int
LastSeen time.Time
}
// Gateway interface for accessing gateway functionality
type Gateway interface {
GetPublicURL() string
IsValidInfoHash(infoHash string) bool
GetWebSeedURL(infoHash string) string
}
// PeerInfo represents a peer in the tracker
type PeerInfo struct {
PeerID string `json:"peer_id"`
IP string `json:"ip"`
Port int `json:"port"`
Uploaded int64 `json:"uploaded"`
Downloaded int64 `json:"downloaded"`
Left int64 `json:"left"`
LastSeen time.Time `json:"last_seen"`
Event string `json:"event"`
Key string `json:"key"`
Compact bool `json:"compact"`
}
// AnnounceRequest represents an announce request from a peer
type AnnounceRequest struct {
InfoHash string `json:"info_hash"`
PeerID string `json:"peer_id"`
Port int `json:"port"`
Uploaded int64 `json:"uploaded"`
Downloaded int64 `json:"downloaded"`
Left int64 `json:"left"`
Event string `json:"event"`
IP string `json:"ip"`
NumWant int `json:"numwant"`
Key string `json:"key"`
Compact bool `json:"compact"`
}
// AnnounceResponse represents the tracker's response to an announce
type AnnounceResponse struct {
FailureReason string `bencode:"failure reason,omitempty"`
WarningMessage string `bencode:"warning message,omitempty"`
Interval int `bencode:"interval"`
MinInterval int `bencode:"min interval,omitempty"`
TrackerID string `bencode:"tracker id,omitempty"`
Complete int `bencode:"complete"`
Incomplete int `bencode:"incomplete"`
Peers interface{} `bencode:"peers"`
}
// CompactPeer represents a peer in compact format (6 bytes: 4 for IP, 2 for port)
type CompactPeer struct {
IP [4]byte
Port uint16
}
// DictPeer represents a peer in dictionary format
type DictPeer struct {
PeerID string `bencode:"peer id"`
IP string `bencode:"ip"`
Port int `bencode:"port"`
}
// NewTracker creates a new tracker instance
func NewTracker(config *config.TrackerConfig, gateway Gateway) *Tracker {
t := &Tracker{
peers: make(map[string]map[string]*PeerInfo),
config: config,
gateway: gateway,
startTime: time.Now(),
}
// Start cleanup routine
go t.cleanupRoutine()
return t
}
// SetCoordinator sets the P2P coordinator for integration
func (t *Tracker) SetCoordinator(coordinator P2PCoordinator) {
t.coordinator = coordinator
}
// detectAbuse checks for suspicious announce patterns
func (t *Tracker) detectAbuse(req *AnnounceRequest, clientIP string) bool {
// Check for too frequent announces from same IP
if t.isAnnounceSpam(clientIP, req.InfoHash) {
log.Printf("Abuse detected: Too frequent announces from IP %s", clientIP)
return true
}
// Check for invalid peer_id patterns
if t.isInvalidPeerID(req.PeerID) {
log.Printf("Abuse detected: Invalid peer_id pattern from IP %s", clientIP)
return true
}
// Check for suspicious port numbers
if t.isSuspiciousPort(req.Port) {
log.Printf("Abuse detected: Suspicious port %d from IP %s", req.Port, clientIP)
return true
}
// Check for known bad actors (would be a database in production)
if t.isKnownBadActor(clientIP) {
log.Printf("Abuse detected: Known bad actor IP %s", clientIP)
return true
}
return false
}
// Abuse detection helper methods
func (t *Tracker) isAnnounceSpam(clientIP, infoHash string) bool {
// In production, this would check a time-windowed database
// For now, use simple in-memory tracking
_ = clientIP + ":" + infoHash // Would be used for tracking
// Simple spam detection: more than 10 announces per minute
// This would be more sophisticated in production
return false // Placeholder
}
func (t *Tracker) isInvalidPeerID(peerID string) bool {
// Check for invalid peer_id patterns
if len(peerID) != 20 {
return true
}
// Check for all zeros or all same character (suspicious)
allSame := true
firstChar := peerID[0]
for i := 1; i < len(peerID); i++ {
if peerID[i] != firstChar {
allSame = false
break
}
}
return allSame
}
func (t *Tracker) isSuspiciousPort(port int) bool {
// Flag potentially suspicious ports
suspiciousPorts := map[int]bool{
22: true, // SSH
23: true, // Telnet
25: true, // SMTP
53: true, // DNS
80: true, // HTTP (web servers shouldn't be P2P clients)
135: true, // Windows RPC
139: true, // NetBIOS
443: true, // HTTPS (web servers shouldn't be P2P clients)
445: true, // SMB
993: true, // IMAPS
995: true, // POP3S
1433: true, // SQL Server
3389: true, // RDP
5432: true, // PostgreSQL
}
// Ports < 1024 are privileged and suspicious for P2P
// Ports > 65535 are invalid
return suspiciousPorts[port] || port < 1024 || port > 65535
}
func (t *Tracker) isKnownBadActor(clientIP string) bool {
// In production, this would check against:
// - Blocklists from organizations like Bluetack
// - Local abuse database
// - Cloud provider IP ranges (if configured to block)
// For now, just block obvious local/private ranges if configured
privateRanges := []string{
"192.168.", "10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
}
// Only block private IPs if we're in a production environment
// (you wouldn't want to block private IPs in development)
for _, prefix := range privateRanges {
if strings.HasPrefix(clientIP, prefix) {
// In development, allow private IPs
return false
}
}
return false
}
// applyClientCompatibility adjusts response for specific BitTorrent clients
func (t *Tracker) applyClientCompatibility(userAgent string, response *AnnounceResponse) {
client := t.detectClient(userAgent)
switch client {
case "qBittorrent":
// qBittorrent works well with default settings
// No special adjustments needed
case "Transmission":
// Transmission prefers shorter intervals
if response.Interval > 1800 {
response.Interval = 1800 // Max 30 minutes
}
case "WebTorrent":
// WebTorrent needs specific adjustments for web compatibility
// Ensure reasonable intervals for web clients
if response.Interval > 300 {
response.Interval = 300 // Max 5 minutes for web clients
}
if response.MinInterval > 60 {
response.MinInterval = 60 // Min 1 minute for web clients
}
case "Deluge":
// Deluge can handle longer intervals
// No special adjustments needed
case "uTorrent":
// uTorrent specific compatibility
// Some versions have issues with very short intervals
if response.MinInterval < 60 {
response.MinInterval = 60
}
}
}
// detectClient identifies BitTorrent client from User-Agent
func (t *Tracker) detectClient(userAgent string) string {
if userAgent == "" {
return "Unknown"
}
userAgent = strings.ToLower(userAgent)
if strings.Contains(userAgent, "qbittorrent") {
return "qBittorrent"
}
if strings.Contains(userAgent, "transmission") {
return "Transmission"
}
if strings.Contains(userAgent, "webtorrent") {
return "WebTorrent"
}
if strings.Contains(userAgent, "deluge") {
return "Deluge"
}
if strings.Contains(userAgent, "utorrent") || strings.Contains(userAgent, "µtorrent") {
return "uTorrent"
}
if strings.Contains(userAgent, "libtorrent") {
return "libtorrent"
}
if strings.Contains(userAgent, "azureus") || strings.Contains(userAgent, "vuze") {
return "Azureus"
}
if strings.Contains(userAgent, "bitcomet") {
return "BitComet"
}
return "Unknown"
}
// getClientIP extracts the real client IP address
func getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first (proxy/load balancer)
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// Take the first IP in the chain
if ips := strings.Split(xff, ","); len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}
// Check X-Real-IP header (nginx proxy)
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
// Fall back to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr // Return as-is if can't parse
}
return ip
}
// HandleAnnounce processes announce requests from peers
func (t *Tracker) HandleAnnounce(w http.ResponseWriter, r *http.Request) {
// Get client IP for abuse detection
clientIP := getClientIP(r)
// Parse announce request
req, err := t.parseAnnounceRequest(r)
if err != nil {
t.writeErrorResponse(w, fmt.Sprintf("Invalid announce request: %v", err))
return
}
// Detect and prevent abuse
if t.detectAbuse(req, clientIP) {
t.writeErrorResponse(w, "Request rejected due to abuse detection")
return
}
// Validate info hash with gateway
if !t.gateway.IsValidInfoHash(req.InfoHash) {
t.writeErrorResponse(w, "Invalid info_hash")
return
}
// Process the announce with client compatibility
resp := t.processAnnounce(req)
t.applyClientCompatibility(r.Header.Get("User-Agent"), resp)
// Write response
w.Header().Set("Content-Type", "text/plain")
data, err := bencode.Marshal(resp)
if err != nil {
t.writeErrorResponse(w, "Internal server error")
return
}
w.Write(data)
}
// parseAnnounceRequest extracts announce parameters from HTTP request
func (t *Tracker) parseAnnounceRequest(r *http.Request) (*AnnounceRequest, error) {
query := r.URL.Query()
// Required parameters
infoHashHex := query.Get("info_hash")
if infoHashHex == "" {
return nil, fmt.Errorf("missing info_hash")
}
// URL decode the info_hash
infoHash, err := url.QueryUnescape(infoHashHex)
if err != nil {
return nil, fmt.Errorf("invalid info_hash encoding")
}
infoHashStr := hex.EncodeToString([]byte(infoHash))
peerID := query.Get("peer_id")
if peerID == "" {
return nil, fmt.Errorf("missing peer_id")
}
portStr := query.Get("port")
if portStr == "" {
return nil, fmt.Errorf("missing port")
}
port, err := strconv.Atoi(portStr)
if err != nil || port <= 0 || port > 65535 {
return nil, fmt.Errorf("invalid port")
}
// Parse numeric parameters
uploaded, _ := strconv.ParseInt(query.Get("uploaded"), 10, 64)
downloaded, _ := strconv.ParseInt(query.Get("downloaded"), 10, 64)
left, _ := strconv.ParseInt(query.Get("left"), 10, 64)
// Optional parameters
event := query.Get("event")
numWantStr := query.Get("numwant")
numWant := t.config.DefaultNumWant
if numWantStr != "" {
if nw, err := strconv.Atoi(numWantStr); err == nil && nw > 0 {
numWant = nw
if numWant > t.config.MaxNumWant {
numWant = t.config.MaxNumWant
}
}
}
compact := query.Get("compact") == "1"
key := query.Get("key")
// Get client IP
ip := t.getClientIP(r)
return &AnnounceRequest{
InfoHash: infoHashStr,
PeerID: peerID,
Port: port,
Uploaded: uploaded,
Downloaded: downloaded,
Left: left,
Event: event,
IP: ip,
NumWant: numWant,
Key: key,
Compact: compact,
}, nil
}
// processAnnounce handles the announce logic and returns a response
func (t *Tracker) processAnnounce(req *AnnounceRequest) *AnnounceResponse {
t.mutex.Lock()
defer t.mutex.Unlock()
// Initialize torrent peer map if not exists
if t.peers[req.InfoHash] == nil {
t.peers[req.InfoHash] = make(map[string]*PeerInfo)
}
torrentPeers := t.peers[req.InfoHash]
// Handle peer events
switch req.Event {
case "stopped":
delete(torrentPeers, req.PeerID)
default:
// Update or add peer
peer := &PeerInfo{
PeerID: req.PeerID,
IP: req.IP,
Port: req.Port,
Uploaded: req.Uploaded,
Downloaded: req.Downloaded,
Left: req.Left,
LastSeen: time.Now(),
Event: req.Event,
Key: req.Key,
Compact: req.Compact,
}
torrentPeers[req.PeerID] = peer
// Notify coordinator of new peer connection
if t.coordinator != nil {
coordPeer := CoordinatorPeerInfo{
IP: peer.IP,
Port: peer.Port,
PeerID: peer.PeerID,
Source: "tracker",
Quality: 70, // Tracker peers have good quality
LastSeen: peer.LastSeen,
}
t.coordinator.OnPeerConnect(req.InfoHash, coordPeer)
// Announce to external services (DHT, etc.) for new torrents
if req.Event == "started" {
go func() {
if err := t.coordinator.AnnounceToExternalServices(req.InfoHash, req.Port); err != nil {
log.Printf("Failed to announce to external services: %v", err)
}
}()
}
}
}
// Count seeders and leechers
complete, incomplete := t.countPeers(torrentPeers)
// Get peer list for response
peers := t.getPeerList(req, torrentPeers)
return &AnnounceResponse{
Interval: t.config.AnnounceInterval,
MinInterval: t.config.MinInterval,
Complete: complete,
Incomplete: incomplete,
Peers: peers,
}
}
// getPeerList returns a list of peers using coordinator for unified peer discovery
func (t *Tracker) getPeerList(req *AnnounceRequest, torrentPeers map[string]*PeerInfo) interface{} {
var selectedPeers []*PeerInfo
// Use coordinator for unified peer discovery if available
if t.coordinator != nil {
coordinatorPeers := t.coordinator.GetPeers(req.InfoHash)
// Convert coordinator peers to tracker format
for _, coordPeer := range coordinatorPeers {
// Skip the requesting peer
if coordPeer.PeerID == req.PeerID {
continue
}
trackerPeer := &PeerInfo{
PeerID: coordPeer.PeerID,
IP: coordPeer.IP,
Port: coordPeer.Port,
Left: 0, // Assume seeder if from coordinator
LastSeen: coordPeer.LastSeen,
}
selectedPeers = append(selectedPeers, trackerPeer)
if len(selectedPeers) >= req.NumWant {
break
}
}
} else {
// Fallback to local tracker peers + WebSeed
// Always include gateway as WebSeed peer if we have WebSeed URL
webSeedURL := t.gateway.GetWebSeedURL(req.InfoHash)
if webSeedURL != "" {
// Parse gateway URL to get IP and port
if u, err := url.Parse(t.gateway.GetPublicURL()); err == nil {
host := u.Hostname()
portStr := u.Port()
if portStr == "" {
portStr = "80"
if u.Scheme == "https" {
portStr = "443"
}
}
if port, err := strconv.Atoi(portStr); err == nil {
gatewyPeer := &PeerInfo{
PeerID: generateWebSeedPeerID(),
IP: host,
Port: port,
Left: 0, // Gateway is always a seeder
LastSeen: time.Now(),
}
selectedPeers = append(selectedPeers, gatewyPeer)
}
}
}
// Add other peers (excluding the requesting peer)
count := 0
for peerID, peer := range torrentPeers {
if peerID != req.PeerID && count < req.NumWant {
selectedPeers = append(selectedPeers, peer)
count++
}
}
}
// Return in requested format
if req.Compact {
return t.createCompactPeerList(selectedPeers)
}
return t.createDictPeerList(selectedPeers)
}
// createCompactPeerList creates compact peer list (6 bytes per peer)
func (t *Tracker) createCompactPeerList(peers []*PeerInfo) []byte {
var compactPeers []byte
for _, peer := range peers {
ip := net.ParseIP(peer.IP)
if ip == nil {
continue
}
// Convert to IPv4
ipv4 := ip.To4()
if ipv4 == nil {
continue
}
// 6 bytes: 4 for IP, 2 for port
peerBytes := make([]byte, 6)
copy(peerBytes[0:4], ipv4)
peerBytes[4] = byte(peer.Port >> 8)
peerBytes[5] = byte(peer.Port & 0xFF)
compactPeers = append(compactPeers, peerBytes...)
}
return compactPeers
}
// createDictPeerList creates dictionary peer list
func (t *Tracker) createDictPeerList(peers []*PeerInfo) []DictPeer {
var dictPeers []DictPeer
for _, peer := range peers {
dictPeers = append(dictPeers, DictPeer{
PeerID: peer.PeerID,
IP: peer.IP,
Port: peer.Port,
})
}
return dictPeers
}
// countPeers counts seeders and leechers
func (t *Tracker) countPeers(torrentPeers map[string]*PeerInfo) (complete, incomplete int) {
for _, peer := range torrentPeers {
if peer.Left == 0 {
complete++
} else {
incomplete++
}
}
return
}
// getClientIP extracts the client IP from the request
func (t *Tracker) getClientIP(r *http.Request) string {
// Check X-Forwarded-For header first
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
// Take the first IP in the chain
parts := strings.Split(xff, ",")
ip := strings.TrimSpace(parts[0])
if net.ParseIP(ip) != nil {
return ip
}
}
// Check X-Real-IP header
xri := r.Header.Get("X-Real-IP")
if xri != "" && net.ParseIP(xri) != nil {
return xri
}
// Fall back to RemoteAddr
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// writeErrorResponse writes an error response in bencode format
func (t *Tracker) writeErrorResponse(w http.ResponseWriter, message string) {
resp := map[string]interface{}{
"failure reason": message,
}
w.Header().Set("Content-Type", "text/plain")
data, _ := bencode.Marshal(resp)
w.Write(data)
}
// cleanupRoutine periodically removes expired peers
func (t *Tracker) cleanupRoutine() {
ticker := time.NewTicker(t.config.CleanupInterval)
defer ticker.Stop()
for range ticker.C {
t.cleanupExpiredPeers()
}
}
// cleanupExpiredPeers removes peers that haven't announced recently
func (t *Tracker) cleanupExpiredPeers() {
t.mutex.Lock()
defer t.mutex.Unlock()
now := time.Now()
expiry := now.Add(-t.config.PeerTimeout)
for infoHash, torrentPeers := range t.peers {
for peerID, peer := range torrentPeers {
if peer.LastSeen.Before(expiry) {
delete(torrentPeers, peerID)
}
}
// Remove empty torrent entries
if len(torrentPeers) == 0 {
delete(t.peers, infoHash)
}
}
}
// generateWebSeedPeerID generates a consistent peer ID for the gateway WebSeed
func generateWebSeedPeerID() string {
// Use a predictable prefix for WebSeed peers
prefix := "-GT0001-" // Gateway Tracker v0.0.1
// Generate random suffix
suffix := make([]byte, 6)
rand.Read(suffix)
return prefix + hex.EncodeToString(suffix)
}
// GetStats returns tracker statistics
func (t *Tracker) GetStats() map[string]interface{} {
t.mutex.RLock()
defer t.mutex.RUnlock()
totalTorrents := len(t.peers)
totalPeers := 0
totalSeeders := 0
totalLeechers := 0
for _, torrentPeers := range t.peers {
totalPeers += len(torrentPeers)
for _, peer := range torrentPeers {
if peer.Left == 0 {
totalSeeders++
} else {
totalLeechers++
}
}
}
return map[string]interface{}{
"torrents": totalTorrents,
"peers": totalPeers,
"seeders": totalSeeders,
"leechers": totalLeechers,
}
}