Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 42 additions & 54 deletions internal/tracker/announce.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package tracker

import (
"database/sql"
"encoding/hex"
"log"
"net"
"net/http"
Expand All @@ -24,49 +23,54 @@ func TrackerError(w http.ResponseWriter, reason string) {
}
}

func HandleAnnounce(w http.ResponseWriter, r *http.Request) {
// In serverless environments, background goroutines might not run.
// We run a probabilistic prune on incoming requests.
MaybePrunePeers()

// 1. Extract and Verify Passkey (Path-based)
// Expects /announce/USER_ID.SIGNATURE
// authenticatePasskey extracts and verifies the path-based passkey
// (/announce/USER_ID.SIGNATURE). Returns userID + true on success, or
// writes a TrackerError and returns false on failure.
func authenticatePasskey(w http.ResponseWriter, r *http.Request) (string, bool) {
pathParts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
var userID string
if len(pathParts) >= 2 && pathParts[0] == "announce" {
var err error
userID, err = VerifyPasskey(pathParts[1])
userID, err := VerifyPasskey(pathParts[1])
if err != nil {
log.Printf("Passkey auth failed: %v", err)
TrackerError(w, "Unauthorized")
return
return "", false
}
} else if os.Getenv("TRACKER_SECRET") != "" {
// If secret is set, we ENFORCE signed passkeys
return userID, true
}
if os.Getenv("TRACKER_SECRET") != "" {
TrackerError(w, "Unauthorized: Passkey required in path (/announce/ID.SIG)")
return
return "", false
}
return "", true
}

q := r.URL.Query()
hashRaw := q.Get("info_hash")
peerID := q.Get("peer_id")
port := q.Get("port")
event := q.Get("event")
func HandleAnnounce(w http.ResponseWriter, r *http.Request) {
// In serverless environments, background goroutines might not run.
// We run a probabilistic prune on incoming requests.
MaybePrunePeers()

if hashRaw == "" || peerID == "" || port == "" {
TrackerError(w, "Missing required parameters (info_hash, peer_id, port)")
// Auth — separate concern from input recognition (path-based, not query).
userID, ok := authenticatePasskey(w, r)
if !ok {
return
}

// Accept both v1 (20-byte SHA-1) and v2 (32-byte SHA-256) info hashes
// to support hybrid torrents where clients may announce with either hash.
if len(hashRaw) != 20 && len(hashRaw) != 32 {
TrackerError(w, "Invalid info_hash: must be 20 bytes (v1) or 32 bytes (v2)")
// Recognize: full validation before any execution.
params, err := RecognizeAnnounce(r.URL.Query())
if err != nil {
TrackerError(w, "Invalid announce: "+err.Error())
return
}

// Convert binary hash to hex string for database lookups
hash := hex.EncodeToString([]byte(hashRaw))
executeAnnounce(w, r, userID, params)
}

// executeAnnounce runs the business logic on a fully-recognized AnnounceParams.
// No parsing or input validation happens here — every field is already typed
// and bounded.
func executeAnnounce(w http.ResponseWriter, r *http.Request, userID string, p AnnounceParams) {
hash := p.InfoHashHex()
peerID := p.PeerIDString()

// Check blocklist
var blocked int
Expand Down Expand Up @@ -94,31 +98,17 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) {
TrackerError(w, "Invalid remote address")
return
}
addr := net.JoinHostPort(clientIP, port)

// Parse stats (BEP 3) — defaults to 0 on missing/malformed values per spec
left, err := strconv.ParseInt(q.Get("left"), 10, 64)
if err != nil {
left = 0
}
downloaded, err := strconv.ParseInt(q.Get("downloaded"), 10, 64)
if err != nil {
downloaded = 0
}
uploaded, err := strconv.ParseInt(q.Get("uploaded"), 10, 64)
if err != nil {
uploaded = 0
}
addr := net.JoinHostPort(clientIP, strconv.Itoa(int(p.Port)))

if event == "stopped" {
if p.Event == EventStopped {
State.RemovePeer(hash, peerID)
} else {
// Calculate session delta for seeder economy
if userID != "" {
oldPeer := State.GetPeer(hash, peerID)
if oldPeer != nil {
deltaUp := uploaded - oldPeer.Uploaded
deltaDown := downloaded - oldPeer.Downloaded
deltaUp := p.Uploaded - oldPeer.Uploaded
deltaDown := p.Downloaded - oldPeer.Downloaded
// Sanity check: prevent negative deltas if client resets counters
if deltaUp > 0 || deltaDown > 0 {
State.TrackUsage(userID, deltaUp, deltaDown)
Expand All @@ -129,13 +119,13 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) {
State.UpdatePeer(hash, peerID, &Peer{
Addr: addr,
UpdatedAt: time.Now().Unix(),
Left: left,
Downloaded: downloaded,
Uploaded: uploaded,
Left: p.Left,
Downloaded: p.Downloaded,
Uploaded: p.Uploaded,
})

// Track completions for scrape (fire-and-forget)
if event == "completed" {
if p.Event == EventCompleted {
if _, err := DB.Exec("UPDATE registry SET completions = completions + 1 WHERE info_hash = ?", hash); err != nil {
log.Printf("Error updating completions: %v", err)
}
Expand All @@ -144,10 +134,8 @@ func HandleAnnounce(w http.ResponseWriter, r *http.Request) {

// Determine peer limit: min(numwant, MaxPeers)
limit := MaxPeers
if nw := q.Get("numwant"); nw != "" {
if n, err := strconv.Atoi(nw); err == nil && n > 0 && n < limit {
limit = n
}
if p.NumWant >= 0 && p.NumWant < limit {
limit = p.NumWant
}

// Fetch swarm from memory (excluding requester)
Expand Down
118 changes: 103 additions & 15 deletions internal/tracker/announce_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tracker

import (
"bytes"
"database/sql"
"encoding/hex"
"net/http"
Expand All @@ -16,10 +17,21 @@ import (
// v2Hash is a 32-byte hex string for BEP 52 info_hash in tests.
const v2Hash = "0123456789012345678901234567890101234567890123456789012345678901" // exactly 64 hex chars

// announceURL builds an announce URL with a binary info_hash (decoded from hex).
// pid20 pads or truncates s to exactly 20 bytes — the peer_id length BEP 3
// mandates and the recognizer enforces.
func pid20(s string) string {
b := []byte(s)
if len(b) >= 20 {
return string(b[:20])
}
return string(append(b, bytes.Repeat([]byte("x"), 20-len(b))...))
}

// announceURL builds an announce URL with a binary info_hash (decoded from hex)
// and a 20-byte peer_id (padded via pid20).
func buildAnnounceURL(hashHex, peerID, port string, extra ...string) string {
bin, _ := hex.DecodeString(hashHex)
u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=" + peerID + "&port=" + port
u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=" + url.QueryEscape(pid20(peerID)) + "&port=" + port
for _, e := range extra {
u += "&" + e
}
Expand Down Expand Up @@ -68,9 +80,9 @@ func TestAnnounceMissingParams(t *testing.T) {
defer db.Close()

tests := []string{
"/announce?peer_id=peer001&port=6881", // missing info_hash
"/announce?info_hash=" + url.QueryEscape(v2Hash) + "&port=6881", // missing peer_id
"/announce?info_hash=" + url.QueryEscape(v2Hash) + "&peer_id=peer001", // missing port
"/announce?peer_id=" + url.QueryEscape(pid20("peer001")) + "&port=6881", // missing info_hash
"/announce?info_hash=" + url.QueryEscape(v2Hash) + "&port=6881", // missing peer_id
"/announce?info_hash=" + url.QueryEscape(v2Hash) + "&peer_id=" + url.QueryEscape(pid20("peer001")), // missing port
}

for _, u := range tests {
Expand Down Expand Up @@ -127,6 +139,79 @@ func TestAnnounceRejectsShortHash(t *testing.T) {
}
}

func TestAnnounceRejectsShortPeerID(t *testing.T) {
db := setupTest(t)
defer db.Close()

bin, _ := hex.DecodeString(v2Hash)
// peer_id only 8 bytes — recognizer must reject
u := "/announce?info_hash=" + url.QueryEscape(string(bin)) + "&peer_id=shorty00&port=6881"
req := httptest.NewRequest("GET", u, nil)
req.RemoteAddr = "127.0.0.1:5000"
w := httptest.NewRecorder()
HandleAnnounce(w, req)

body := w.Body.String()
if !strings.Contains(body, "peer_id must be exactly 20") {
t.Errorf("Expected typed peer_id rejection, got: %s", body)
}
}

func TestAnnounceRejectsBadPort(t *testing.T) {
db := setupTest(t)
defer db.Close()

cases := []struct{ port, wantSub string }{
{"0", "port"},
{"abc", "port"},
{"99999", "port"},
{"-1", "port"},
}
for _, c := range cases {
t.Run("port="+c.port, func(t *testing.T) {
req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", c.port), nil)
req.RemoteAddr = "127.0.0.1:5000"
w := httptest.NewRecorder()
HandleAnnounce(w, req)

body := w.Body.String()
if !strings.Contains(body, "failure reason") || !strings.Contains(body, c.wantSub) {
t.Errorf("Expected port rejection, got: %s", body)
}
})
}
}

func TestAnnounceRejectsBadEvent(t *testing.T) {
db := setupTest(t)
defer db.Close()

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", "6881", "event=foo"), nil)
req.RemoteAddr = "127.0.0.1:5000"
w := httptest.NewRecorder()
HandleAnnounce(w, req)

body := w.Body.String()
if !strings.Contains(body, "event") {
t.Errorf("Expected event rejection, got: %s", body)
}
}

func TestAnnounceRejectsNegativeStats(t *testing.T) {
db := setupTest(t)
defer db.Close()

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer001", "6881", "uploaded=-5"), nil)
req.RemoteAddr = "127.0.0.1:5000"
w := httptest.NewRecorder()
HandleAnnounce(w, req)

body := w.Body.String()
if !strings.Contains(body, "uploaded") {
t.Errorf("Expected uploaded rejection, got: %s", body)
}
}

func TestAnnounceMultiplePeers(t *testing.T) {
db := setupTest(t)
defer db.Close()
Expand Down Expand Up @@ -165,7 +250,7 @@ func TestAnnouncePeerUpdate(t *testing.T) {
w2 := httptest.NewRecorder()
HandleAnnounce(w2, req2)

// Check in-memory state
// Check in-memory state — same peer_id should have only one entry
peers := State.GetPeers(v2Hash, "none", 10)
if len(peers) != 1 {
t.Errorf("Expected 1 peer, got %d", len(peers))
Expand All @@ -176,7 +261,7 @@ func TestAnnounceWithStoppedEvent(t *testing.T) {
db := setupTest(t)
defer db.Close()

State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()})

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer1", "6881", "event=stopped"), nil)
req.RemoteAddr = "127.0.0.1:5000"
Expand All @@ -193,8 +278,8 @@ func TestAnnounceExcludesRequester(t *testing.T) {
db := setupTest(t)
defer db.Close()

State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, "peer2", &Peer{Addr: "127.0.0.1:6882", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "127.0.0.1:6881", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20("peer2"), &Peer{Addr: "127.0.0.1:6882", UpdatedAt: time.Now().Unix()})

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "peer1", "6881"), nil)
req.RemoteAddr = "127.0.0.1:5000"
Expand Down Expand Up @@ -228,7 +313,7 @@ func TestAnnounceTracksStats(t *testing.T) {

// Check memory state
State.mu.RLock()
peer := State.Peers[v2Hash]["seeder"]
peer := State.Peers[v2Hash][pid20("seeder")]
State.mu.RUnlock()
if peer == nil || peer.Left != 0 || peer.Downloaded != 1000 || peer.Uploaded != 500 {
t.Errorf("Stats not stored correctly: %+v", peer)
Expand Down Expand Up @@ -256,7 +341,7 @@ func TestAnnounceNumwant(t *testing.T) {
defer db.Close()

for i := 0; i < 5; i++ {
State.UpdatePeer(v2Hash, string(rune('a'+i)), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20(string(rune('a'+i))), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()})
}

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "requester", "9999", "numwant=2"), nil)
Expand All @@ -283,7 +368,7 @@ func TestAnnounceMaxPeersCap(t *testing.T) {
defer func() { MaxPeers = oldMax }()

for i := 0; i < 5; i++ {
State.UpdatePeer(v2Hash, string(rune('a'+i)), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20(string(rune('a'+i))), &Peer{Addr: "127.0.0.1:1000" + string(rune('1'+i)), UpdatedAt: time.Now().Unix()})
}

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "requester", "9999", "numwant=10"), nil)
Expand All @@ -305,7 +390,7 @@ func TestAnnounceCompactFormat(t *testing.T) {
db := setupTest(t)
defer db.Close()

State.UpdatePeer(v2Hash, "peer1", &Peer{Addr: "192.168.1.1:6881", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20("peer1"), &Peer{Addr: "192.168.1.1:6881", UpdatedAt: time.Now().Unix()})

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "other", "9999"), nil)
req.RemoteAddr = "10.0.0.1:5000"
Expand Down Expand Up @@ -334,7 +419,7 @@ func TestAnnounceIPv6(t *testing.T) {
db := setupTest(t)
defer db.Close()

State.UpdatePeer(v2Hash, "peer6", &Peer{Addr: "[::1]:6881", UpdatedAt: time.Now().Unix()})
State.UpdatePeer(v2Hash, pid20("peer6"), &Peer{Addr: "[::1]:6881", UpdatedAt: time.Now().Unix()})

req := httptest.NewRequest("GET", buildAnnounceURL(v2Hash, "other", "9999"), nil)
req.RemoteAddr = "10.0.0.1:5000"
Expand Down Expand Up @@ -415,7 +500,10 @@ func TestAnnounceCompletionDBError(t *testing.T) {
w := httptest.NewRecorder()
HandleAnnounce(w, req)

// The announce should still succeed — completion tracking is fire-and-forget
// With OPEN_TRACKER not set, the missing registry table now causes the
// recognizer-passing announce to fail at the registry-lookup step
// (Unregistered torrent). That's still a graceful 200 + bencoded failure,
// not a server error.
if w.Code != http.StatusOK {
t.Errorf("Expected 200 (graceful degradation), got %d", w.Code)
}
Expand Down
Loading
Loading