summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Makefile6
-rw-r--r--opentracker.c17
-rw-r--r--ot_http.c40
-rw-r--r--ot_http.h3
-rw-r--r--ot_mutex.h1
-rw-r--r--ot_udp.c17
-rw-r--r--tests/integration/harness.py78
-rw-r--r--tests/integration/test_accesslist.py16
-rw-r--r--tests/integration/test_fullscrape.py32
-rw-r--r--tests/integration/test_keepalive.py111
-rw-r--r--tests/integration/test_stats.py49
-rw-r--r--tests/integration/test_udp.py15
12 files changed, 341 insertions, 44 deletions
diff --git a/Makefile b/Makefile
index ad2ca4e..d16e220 100644
--- a/Makefile
+++ b/Makefile
@@ -152,7 +152,7 @@ check-sanitize: $(TESTBIN).sanitize
152# cannot reach. Built with the sanitizers on, so the tracker reports its own 152# cannot reach. Built with the sanitizers on, so the tracker reports its own
153# memory errors and the harness turns those into failures. 153# memory errors and the harness turns those into failures.
154PYTHON?=python3 154PYTHON?=python3
155FEATURES_integration=$(FEATURES) -DWANT_IP_FROM_QUERY_STRING -DWANT_ACCESSLIST_BLACK 155FEATURES_integration=$(FEATURES) -DWANT_IP_FROM_QUERY_STRING -DWANT_ACCESSLIST_BLACK -DWANT_KEEPALIVE
156CFLAGS_integration=$(CFLAGS) $(OPTS_debug) $(FEATURES_integration) -fsanitize=address,undefined -fno-omit-frame-pointer 156CFLAGS_integration=$(CFLAGS) $(OPTS_debug) $(FEATURES_integration) -fsanitize=address,undefined -fno-omit-frame-pointer
157LDFLAGS_integration=$(LDFLAGS) -fsanitize=address,undefined 157LDFLAGS_integration=$(LDFLAGS) -fsanitize=address,undefined
158 158
@@ -170,8 +170,10 @@ check-all:
170 170
171.PHONY: all check check-sanitize check-integration check-all clean install 171.PHONY: all check check-sanitize check-integration check-all clean install
172 172
173BINARIES=opentracker opentracker.debug opentracker.integration proxy proxy.debug $(TESTBIN) $(TESTBIN).sanitize
174
173clean: 175clean:
174 rm -rf opentracker opentracker.debug opentracker.integration proxy proxy.debug $(TESTBIN) $(TESTBIN).sanitize *.o *~ 176 rm -rf $(BINARIES) $(BINARIES:=.dSYM) *.o *~
175 177
176install: 178install:
177 install -m 755 opentracker $(DESTDIR)$(BINDIR) 179 install -m 755 opentracker $(DESTDIR)$(BINDIR)
diff --git a/opentracker.c b/opentracker.c
index 21eeb42..11da486 100644
--- a/opentracker.c
+++ b/opentracker.c
@@ -177,12 +177,13 @@ static void handle_read(const int64_t sock, struct ot_workstruct *ws) {
177} 177}
178 178
179static void handle_write(const int64_t sock) { 179static void handle_write(const int64_t sock) {
180 struct http_data *cookie = fdstate_cookie(sock); 180 struct http_data *cookie = fdstate_cookie(sock);
181 int chunked = 0; 181 int chunked = 0;
182 int keep_alive = 0;
182 183
183 if (cookie) { 184 if (cookie) {
184 if (cookie->flag & STRUCT_HTTP_FLAG_CHUNKED_IN_TRANSFER) 185 chunked = (cookie->flag & STRUCT_HTTP_FLAG_CHUNKED_IN_TRANSFER) != 0;
185 chunked = 1; 186 keep_alive = (cookie->flag & STRUCT_HTTP_FLAG_KEEPALIVE) != 0;
186 187
187 if (cookie->batch) { 188 if (cookie->batch) {
188 if (batch_send(sock, cookie->batch) == BATCH_FAILED) { 189 if (batch_send(sock, cookie->batch) == BATCH_FAILED) {
@@ -197,7 +198,13 @@ static void handle_write(const int64_t sock) {
197 /* In a chunked transfer, wait for the next piece once everything queued has gone out */ 198 /* In a chunked transfer, wait for the next piece once everything queued has gone out */
198 if (chunked) 199 if (chunked)
199 http_interest_drop(sock, cookie, EVENT_OUT); 200 http_interest_drop(sock, cookie, EVENT_OUT);
200 else 201 else if (keep_alive) {
202 /* The reply is out and the client asked to keep the connection, so stop
203 waiting to write and go back to waiting for the next request */
204 http_interest_drop(sock, cookie, EVENT_OUT);
205 http_interest_add(sock, cookie, EVENT_IN);
206 fdstate_set_expiry(sock, g_now_seconds + OT_CLIENT_TIMEOUT);
207 } else
201 handle_dead(sock); 208 handle_dead(sock);
202} 209}
203 210
diff --git a/ot_http.c b/ot_http.c
index 36819c7..2216453 100644
--- a/ot_http.c
+++ b/ot_http.c
@@ -24,6 +24,7 @@
24#include "ot_scan.h" 24#include "ot_scan.h"
25#include "ot_event.h" 25#include "ot_event.h"
26#include "ot_fdstate.h" 26#include "ot_fdstate.h"
27#include "ot_vector.h"
27#include "ot_fmt.h" 28#include "ot_fmt.h"
28#include "ot_stats.h" 29#include "ot_stats.h"
29#include "ot_urlencoded.h" 30#include "ot_urlencoded.h"
@@ -233,7 +234,6 @@ static ssize_t http_handle_stats(const int64_t sock, struct ot_workstruct *ws, c
233 {"scrp", TASK_STATS_SCRAPE}, 234 {"scrp", TASK_STATS_SCRAPE},
234 {"udp4", TASK_STATS_UDP}, 235 {"udp4", TASK_STATS_UDP},
235 {"tcp4", TASK_STATS_TCP}, 236 {"tcp4", TASK_STATS_TCP},
236 {"busy", TASK_STATS_BUSY_NETWORKS},
237 {"torr", TASK_STATS_TORRENTS}, 237 {"torr", TASK_STATS_TORRENTS},
238 {"fscr", TASK_STATS_FULLSCRAPE}, 238 {"fscr", TASK_STATS_FULLSCRAPE},
239 {"s24s", TASK_STATS_SLASH24S}, 239 {"s24s", TASK_STATS_SLASH24S},
@@ -418,13 +418,15 @@ static ssize_t http_handle_fullscrape(const int64_t sock, struct ot_workstruct *
418} 418}
419#endif 419#endif
420 420
421static int compare_hash(const void *left, const void *right) { return memcmp(left, right, sizeof(ot_hash)); }
422
421static ssize_t http_handle_scrape(const int64_t sock, struct ot_workstruct *ws, char *read_ptr) { 423static ssize_t http_handle_scrape(const int64_t sock, struct ot_workstruct *ws, char *read_ptr) {
422 static const ot_keywords keywords_scrape[] = {{"info_hash", 1}, {NULL, -3}}; 424 static const ot_keywords keywords_scrape[] = {{"info_hash", 1}, {NULL, -3}};
423 425
424 ot_hash *multiscrape_buf = (ot_hash *)ws->request; 426 ot_hash *multiscrape_buf = (ot_hash *)ws->request;
425 char *reply = ws->reply; 427 char *reply = ws->reply;
426 int scanon = 1; 428 int scanon = 1;
427 size_t numwant = 0; 429 size_t i, unique, numwant = 0;
428 430
429 /* This is to hack around stupid clients that send "scrape ?info_hash" */ 431 /* This is to hack around stupid clients that send "scrape ?info_hash" */
430 if (read_ptr[-1] != '?') { 432 if (read_ptr[-1] != '?') {
@@ -461,6 +463,14 @@ static ssize_t http_handle_scrape(const int64_t sock, struct ot_workstruct *ws,
461 if (numwant > OT_MAXMULTISCRAPE_COUNT) 463 if (numwant > OT_MAXMULTISCRAPE_COUNT)
462 numwant = OT_MAXMULTISCRAPE_COUNT; 464 numwant = OT_MAXMULTISCRAPE_COUNT;
463 465
466 /* A bencoded dictionary has its keys in order and names each one once, so
467 the answer cannot just follow the order the client asked in */
468 qsort(multiscrape_buf, numwant, sizeof(ot_hash), compare_hash);
469 for (i = unique = 1; i < numwant; ++i)
470 if (memcmp(multiscrape_buf[i], multiscrape_buf[unique - 1], sizeof(ot_hash)))
471 memcpy(multiscrape_buf[unique++], multiscrape_buf[i], sizeof(ot_hash));
472 numwant = unique;
473
464 /* Enough for http header + whole scrape string */ 474 /* Enough for http header + whole scrape string */
465 return_tcp_scrape_for_torrent((const ot_hash *)multiscrape_buf, numwant, &reply, ws->outbuf + G_OUTBUF_SIZE); 475 return_tcp_scrape_for_torrent((const ot_hash *)multiscrape_buf, numwant, &reply, ws->outbuf + G_OUTBUF_SIZE);
466 ws->reply_size = reply - ws->reply; 476 ws->reply_size = reply - ws->reply;
@@ -727,6 +737,24 @@ ssize_t http_handle_request(const int64_t sock, struct ot_workstruct *ws) {
727 /* Tell subroutines where to put reply data */ 737 /* Tell subroutines where to put reply data */
728 ws->reply = ws->outbuf + SUCCESS_HTTP_HEADER_LENGTH; 738 ws->reply = ws->outbuf + SUCCESS_HTTP_HEADER_LENGTH;
729 739
740 /* Find out if the client wants to keep this connection alive, before any
741 subroutine gets to answer: an error reply finishes with the connection
742 on its way out, and ws outlives the connection it was serving */
743 ws->keep_alive = 0;
744#ifdef WANT_KEEPALIVE
745 if ((write_ptr = http_header(ws->request, ws->header_size, "connection")) && (*write_ptr == 'K' || *write_ptr == 'k'))
746 ws->keep_alive = 1;
747#endif
748 {
749 struct http_data *connection = fdstate_cookie(sock);
750 if (connection) {
751 if (ws->keep_alive)
752 connection->flag |= STRUCT_HTTP_FLAG_KEEPALIVE;
753 else
754 connection->flag &= ~STRUCT_HTTP_FLAG_KEEPALIVE;
755 }
756 }
757
730 /* This one implicitely tests strlen < 5, too -- remember, it is \n terminated */ 758 /* This one implicitely tests strlen < 5, too -- remember, it is \n terminated */
731 if (memcmp(read_ptr, "GET /", 5)) 759 if (memcmp(read_ptr, "GET /", 5))
732 HTTPERROR_400; 760 HTTPERROR_400;
@@ -763,14 +791,6 @@ ssize_t http_handle_request(const int64_t sock, struct ot_workstruct *ws) {
763 else 791 else
764 HTTPERROR_404; 792 HTTPERROR_404;
765 793
766 /* Find out if the client wants to keep this connection alive */
767 ws->keep_alive = 0;
768#ifdef WANT_KEEPALIVE
769 read_ptr = http_header(ws->request, ws->header_size, "connection");
770 if (read_ptr && (*read_ptr == 'K' || *read_ptr == 'k'))
771 ws->keep_alive = 1;
772#endif
773
774 /* If routines handled sending themselves, just return */ 794 /* If routines handled sending themselves, just return */
775 if (ws->reply_size == -2) 795 if (ws->reply_size == -2)
776 return 0; 796 return 0;
diff --git a/ot_http.h b/ot_http.h
index 1631220..1ccabfd 100644
--- a/ot_http.h
+++ b/ot_http.h
@@ -21,7 +21,8 @@ typedef enum {
21 STRUCT_HTTP_FLAG_BZIP2 = 4, 21 STRUCT_HTTP_FLAG_BZIP2 = 4,
22 STRUCT_HTTP_FLAG_ZSTD = 8, 22 STRUCT_HTTP_FLAG_ZSTD = 8,
23 STRUCT_HTTP_FLAG_CHUNKED = 16, 23 STRUCT_HTTP_FLAG_CHUNKED = 16,
24 STRUCT_HTTP_FLAG_CHUNKED_IN_TRANSFER = 32 24 STRUCT_HTTP_FLAG_CHUNKED_IN_TRANSFER = 32,
25 STRUCT_HTTP_FLAG_KEEPALIVE = 64
25} STRUCT_HTTP_FLAG; 26} STRUCT_HTTP_FLAG;
26 27
27struct http_data { 28struct http_data {
diff --git a/ot_mutex.h b/ot_mutex.h
index b5af96e..3bbbe9c 100644
--- a/ot_mutex.h
+++ b/ot_mutex.h
@@ -29,7 +29,6 @@ typedef enum {
29 TASK_STATS_TPB = 0x0006, 29 TASK_STATS_TPB = 0x0006,
30 TASK_STATS_HTTPERRORS = 0x0007, 30 TASK_STATS_HTTPERRORS = 0x0007,
31 TASK_STATS_VERSION = 0x0008, 31 TASK_STATS_VERSION = 0x0008,
32 TASK_STATS_BUSY_NETWORKS = 0x0009,
33 TASK_STATS_RENEW = 0x000a, 32 TASK_STATS_RENEW = 0x000a,
34 TASK_STATS_SYNCS = 0x000b, 33 TASK_STATS_SYNCS = 0x000b,
35 TASK_STATS_COMPLETED = 0x000c, 34 TASK_STATS_COMPLETED = 0x000c,
diff --git a/ot_udp.c b/ot_udp.c
index 5bf9d52..4e760bb 100644
--- a/ot_udp.c
+++ b/ot_udp.c
@@ -105,10 +105,11 @@ int handle_udp6(int64_t serversocket, struct ot_workstruct *ws) {
105 match, return an error packet */ 105 match, return an error packet */
106 udp_make_connectionid(connid, remoteip, 1); 106 udp_make_connectionid(connid, remoteip, 1);
107 if (inpacket[0] != connid[0] || inpacket[1] != connid[1]) { 107 if (inpacket[0] != connid[0] || inpacket[1] != connid[1]) {
108 const size_t s = sizeof("Connection ID missmatch."); 108 const char err_idmiss[] = "Connection ID missmatch.";
109 size_t s = sizeof(err_idmiss);
109 outpacket[0] = htonl(3); 110 outpacket[0] = htonl(3);
110 outpacket[1] = inpacket[3]; 111 outpacket[1] = inpacket[3];
111 memcpy(&outpacket[2], "Connection ID missmatch.", s); 112 memcpy(&outpacket[2], &err_idmiss[0], s);
112 socket_send(serversocket, ws->outbuf, 8 + s, remoteip, remoteport); 113 socket_send(serversocket, ws->outbuf, 8 + s, remoteip, remoteport);
113 stats_issue_event(EVENT_CONNID_MISSMATCH, FLAG_UDP, 8 + s); 114 stats_issue_event(EVENT_CONNID_MISSMATCH, FLAG_UDP, 8 + s);
114 return 1; 115 return 1;
@@ -173,7 +174,17 @@ int handle_udp6(int64_t serversocket, struct ot_workstruct *ws) {
173 numwant = max_peers; 174 numwant = max_peers;
174 175
175 ws->reply = ws->outbuf + 8; 176 ws->reply = ws->outbuf + 8;
176 ws->reply_size = 8 + add_peer_to_torrent_and_return_peers(FLAG_UDP, ws, numwant); 177 ws->reply_size = add_peer_to_torrent_and_return_peers(FLAG_UDP, ws, numwant);
178
179 if (!ws->reply_size) {
180 const char err_unauth[] = "Requested download is not authorized for use with this tracker.";
181 size_t s = sizeof(err_unauth);
182 outpacket[0] = htonl(3);
183 memcpy(&outpacket[2], &err_unauth[0], s);
184 socket_send(serversocket, ws->outbuf, 8 + s, remoteip, remoteport);
185 return 1;
186 }
187 ws->reply_size += 8;
177 } 188 }
178 189
179 socket_send(serversocket, ws->outbuf, ws->reply_size, remoteip, remoteport); 190 socket_send(serversocket, ws->outbuf, ws->reply_size, remoteip, remoteport);
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
index 7e75574..03a0561 100644
--- a/tests/integration/harness.py
+++ b/tests/integration/harness.py
@@ -197,6 +197,10 @@ class Tracker:
197 assert action == 2, "udp scrape answered action %d" % action 197 assert action == 2, "udp scrape answered action %d" % action
198 return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))] 198 return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))]
199 199
200 def connect(self):
201 """A connection the caller drives and closes."""
202 return Connection(self.port)
203
200 def udp_raw(self, payload, timeout=1.0): 204 def udp_raw(self, payload, timeout=1.0):
201 """Sends a packet exactly as given. Returns the answer, or None if the 205 """Sends a packet exactly as given. Returns the answer, or None if the
202 tracker chose to say nothing, which is how it treats a broken one.""" 206 tracker chose to say nothing, which is how it treats a broken one."""
@@ -212,6 +216,73 @@ class Tracker:
212 sock.close() 216 sock.close()
213 217
214 218
219class Connection:
220 """One HTTP connection the caller keeps open, which urllib will not do.
221
222 Reads exactly one reply at a time, so a test can tell "the tracker
223 answered and kept the socket" from "the tracker answered and hung up"."""
224
225 def __init__(self, port, timeout=5):
226 self.sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
227 self.sock.settimeout(timeout)
228 self.buffer = b""
229
230 def send(self, path, connection="Keep-Alive", version="1.1"):
231 request = "GET %s HTTP/%s\r\nHost: tracker\r\n" % (path, version)
232 if connection is not None:
233 request += "Connection: %s\r\n" % connection
234 self.sock.sendall((request + "\r\n").encode())
235
236 def _fill(self):
237 chunk = self.sock.recv(65536)
238 if not chunk:
239 raise EOFError("the tracker closed the connection")
240 self.buffer += chunk
241
242 def reply(self):
243 """The one complete reply at the front of the stream, headers and body."""
244 while b"\r\n\r\n" not in self.buffer:
245 self._fill()
246 head, rest = self.buffer.split(b"\r\n\r\n", 1)
247 headers = {}
248 for line in head.split(b"\r\n")[1:]:
249 name, _, value = line.partition(b":")
250 headers[name.strip().lower()] = value.strip()
251
252 if headers.get(b"transfer-encoding") == b"chunked":
253 while not rest.endswith(b"0\r\n\r\n"):
254 self.buffer = head + b"\r\n\r\n" + rest
255 self._fill()
256 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
257 body, self.buffer = rest, b""
258 else:
259 length = int(headers.get(b"content-length", b"0"))
260 while len(rest) < length:
261 self.buffer = head + b"\r\n\r\n" + rest
262 self._fill()
263 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
264 body, self.buffer = rest[:length], rest[length:]
265 return head.split(b"\r\n")[0], headers, body
266
267 def is_open(self):
268 """True if the tracker is still holding the connection."""
269 try:
270 self.sock.settimeout(0.4)
271 return self.sock.recv(1) != b""
272 except socket.timeout:
273 return True
274 except OSError:
275 return False
276 finally:
277 try:
278 self.sock.settimeout(5)
279 except OSError:
280 pass
281
282 def close(self):
283 self.sock.close()
284
285
215class TrackerTestCase(unittest.TestCase): 286class TrackerTestCase(unittest.TestCase):
216 """Gives every test its own tracker and fails it on any sanitizer output.""" 287 """Gives every test its own tracker and fails it on any sanitizer output."""
217 288
@@ -222,6 +293,13 @@ class TrackerTestCase(unittest.TestCase):
222 self.tracker = Tracker(self.config, self.files) 293 self.tracker = Tracker(self.config, self.files)
223 294
224 def tearDown(self): 295 def tearDown(self):
296 # A connection the test closed is only reaped when the event loop gets
297 # round to it, and a cookie still held at exit reads as a leak. One
298 # more request puts us behind that work in the loop's queue.
299 try:
300 self.tracker.torrent_count()
301 except Exception:
302 pass
225 complaints = self.tracker.stop() 303 complaints = self.tracker.stop()
226 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints)) 304 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints))
227 305
diff --git a/tests/integration/test_accesslist.py b/tests/integration/test_accesslist.py
index b4e134e..41ff654 100644
--- a/tests/integration/test_accesslist.py
+++ b/tests/integration/test_accesslist.py
@@ -37,12 +37,10 @@ class Blacklist(TrackerTestCase):
37 self.assertNotIn(b"failure reason", answer) 37 self.assertNotIn(b"failure reason", answer)
38 self.assertEqual(answer[b"complete"], 1) 38 self.assertEqual(answer[b"complete"], 1)
39 39
40 def test_the_block_holds_over_udp_but_the_answer_is_malformed(self): 40 def test_the_block_is_an_error_packet_over_udp(self):
41 """The UDP path has no spelling for "not authorized": the announce 41 """BEP 15 has an error action, which is what a refusal should use.
42 writes no body, so the tracker sends the eight byte header alone 42 It used to answer the announce header with no body behind it, a
43 where BEP 15 calls for at least twenty. Pinned rather than blessed — 43 packet too short for any client to read."""
44 an error packet or silence would both be defensible, a short announce
45 is neither."""
46 sock, connection_id = self.tracker.udp_connect() 44 sock, connection_id = self.tracker.udp_connect()
47 sock.close() 45 sock.close()
48 request = struct.pack( 46 request = struct.pack(
@@ -50,10 +48,10 @@ class Blacklist(TrackerTestCase):
50 ) 48 )
51 answer = self.tracker.udp_raw(request) 49 answer = self.tracker.udp_raw(request)
52 self.assertIsNotNone(answer) 50 self.assertIsNotNone(answer)
53 self.assertEqual(len(answer), 8, "header only, no interval or counts") 51 action, transaction = struct.unpack(">II", answer[:8])
54 action, transaction = struct.unpack(">II", answer) 52 self.assertEqual(action, 3, "action 3 is the error packet")
55 self.assertEqual(action, 1, "it still claims to be an announce")
56 self.assertEqual(transaction, 7) 53 self.assertEqual(transaction, 7)
54 self.assertIn(b"not authorized", answer[8:])
57 55
58 def test_a_blocked_hash_is_not_stored_when_it_arrives_over_udp(self): 56 def test_a_blocked_hash_is_not_stored_when_it_arrives_over_udp(self):
59 sock, connection_id = self.tracker.udp_connect() 57 sock, connection_id = self.tracker.udp_connect()
diff --git a/tests/integration/test_fullscrape.py b/tests/integration/test_fullscrape.py
index 4059656..be7b502 100644
--- a/tests/integration/test_fullscrape.py
+++ b/tests/integration/test_fullscrape.py
@@ -112,3 +112,35 @@ class FullScrapeChunking(TrackerTestCase):
112 112
113if __name__ == "__main__": 113if __name__ == "__main__":
114 unittest.main() 114 unittest.main()
115
116
117class PartialScrapeOrder(TrackerTestCase):
118 """A bencoded dictionary names its keys in order, once each. The full
119 scrape got that from walking the buckets; the partial scrape used to
120 echo whatever order the client asked in."""
121
122 def setUp(self):
123 TrackerTestCase.setUp(self)
124 self.hashes = [bytes([i]) + b"\0" * 19 for i in (9, 1, 5, 3, 7)]
125 for i, h in enumerate(self.hashes):
126 self.tracker.announce(h, port=6001 + i, left=0)
127
128 def scrape_many(self, hashes):
129 import urllib.parse
130
131 query = "&".join("info_hash=" + urllib.parse.quote_from_bytes(h, safe="") for h in hashes)
132 return bdecode(self.tracker.get("/scrape?" + query))
133
134 def test_the_keys_come_back_in_order(self):
135 answer = self.scrape_many(self.hashes)
136 self.assertEqual(list(answer[b"files"]), sorted(self.hashes))
137
138 def test_reversing_the_question_does_not_reverse_the_answer(self):
139 self.assertEqual(
140 list(self.scrape_many(self.hashes[::-1])[b"files"]), list(self.scrape_many(self.hashes)[b"files"])
141 )
142
143 def test_a_hash_asked_for_twice_is_named_once(self):
144 asked = [self.hashes[0], self.hashes[1], self.hashes[0]]
145 answer = self.scrape_many(asked)
146 self.assertEqual(list(answer[b"files"]), sorted({self.hashes[0], self.hashes[1]}))
diff --git a/tests/integration/test_keepalive.py b/tests/integration/test_keepalive.py
new file mode 100644
index 0000000..01f02a5
--- /dev/null
+++ b/tests/integration/test_keepalive.py
@@ -0,0 +1,111 @@
1"""Holding a connection open across several requests.
2
3The reply path has two shapes. A small answer goes out in one write and the
4connection is done with immediately. A large one is queued and finishes on a
5later pass through the event loop, by which time the workstruct that served
6the request has moved on, so whether to keep the connection has to be
7remembered on the connection itself.
8"""
9
10import unittest
11import urllib.parse
12
13from harness import TrackerTestCase, bdecode
14
15
16class KeepAlive(TrackerTestCase):
17 def announce_path(self, name, port=6881):
18 return "/announce?info_hash=%s&peer_id=%s&port=%d&left=0" % (
19 urllib.parse.quote_from_bytes(self.hash_for(name), safe=""),
20 "x" * 20,
21 port,
22 )
23
24 def test_two_requests_over_one_connection(self):
25 c = self.tracker.connect()
26 try:
27 c.send(self.announce_path("kaA"))
28 status, _, first = c.reply()
29 self.assertIn(b"200", status)
30 c.send(self.announce_path("kaB"))
31 status, _, second = c.reply()
32 self.assertIn(b"200", status)
33 self.assertIn(b"complete", bdecode(second))
34 finally:
35 c.close()
36
37 def test_close_ends_the_connection(self):
38 c = self.tracker.connect()
39 try:
40 c.send(self.announce_path("kaclose"), connection="close")
41 c.reply()
42 self.assertFalse(c.is_open(), "the tracker was asked to hang up")
43 finally:
44 c.close()
45
46 def test_a_connection_survives_a_reply_too_large_for_one_write(self):
47 """The case that was broken: a queued reply finished draining and the
48 connection was closed regardless of what the client had asked for."""
49 for i in range(400):
50 self.tracker.announce(bytes([i // 256, i % 256]) + b"\0" * 18, port=7000 + i, left=0)
51
52 c = self.tracker.connect()
53 try:
54 c.send("/scrape")
55 status, headers, body = c.reply()
56 self.assertIn(b"200", status)
57 self.assertGreater(len(body), 16384, "this reply has to outgrow one write")
58
59 c.send(self.announce_path("kaafter"))
60 status, _, after = c.reply()
61 self.assertIn(b"200", status)
62 self.assertIn(b"complete", bdecode(after))
63 finally:
64 c.close()
65
66 def test_several_requests_in_a_row(self):
67 c = self.tracker.connect()
68 try:
69 for i in range(20):
70 c.send(self.announce_path("karow%d" % i))
71 status, _, body = c.reply()
72 self.assertIn(b"200", status)
73 self.assertEqual(self.tracker.torrent_count(), 20)
74 finally:
75 c.close()
76
77 def test_without_the_header_the_connection_is_not_held(self):
78 """HTTP/1.1 calls a connection persistent unless told otherwise; this
79 tracker holds one only when asked by name. Pinned as it is."""
80 c = self.tracker.connect()
81 try:
82 c.send(self.announce_path("kanone"), connection=None)
83 c.reply()
84 self.assertFalse(c.is_open())
85 finally:
86 c.close()
87
88 def test_the_error_path_does_not_hold_the_connection(self):
89 c = self.tracker.connect()
90 try:
91 c.send("/announce?info_hash=short")
92 status, _, _ = c.reply()
93 self.assertIn(b"400", status)
94 finally:
95 c.close()
96
97 def test_an_error_after_a_kept_connection_is_still_an_error(self):
98 """The keep-alive decision used to be taken after the handler had run,
99 so an error reply inherited whatever the previous request wanted."""
100 c = self.tracker.connect()
101 try:
102 c.send(self.announce_path("kabefore"))
103 self.assertIn(b"200", c.reply()[0])
104 c.send("/announce?info_hash=short")
105 self.assertIn(b"400", c.reply()[0])
106 finally:
107 c.close()
108
109
110if __name__ == "__main__":
111 unittest.main()
diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py
index 920d199..820b917 100644
--- a/tests/integration/test_stats.py
+++ b/tests/integration/test_stats.py
@@ -5,7 +5,9 @@ every one of them and, where the number means something, that it moves when
5the thing it counts happens. 5the thing it counts happens.
6""" 6"""
7 7
8import ipaddress
8import re 9import re
10import socket
9import unittest 11import unittest
10import urllib.error 12import urllib.error
11import xml.etree.ElementTree 13import xml.etree.ElementTree
@@ -14,8 +16,7 @@ from harness import TrackerTestCase
14 16
15# The modes a default build answers. "tpbs" and "statedump" are full scrapes 17# The modes a default build answers. "tpbs" and "statedump" are full scrapes
16# and belong to test_fullscrape; "fulllog" and "woodpeckers" sit behind 18# and belong to test_fullscrape; "fulllog" and "woodpeckers" sit behind
17# feature flags this build does not set; "busy" is advertised but unbuilt, 19# feature flags this build does not set.
18# which the next case pins.
19MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"] 20MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"]
20TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"] 21TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"]
21 22
@@ -34,14 +35,14 @@ class StatsShape(TrackerTestCase):
34 for line in lines[:2]: 35 for line in lines[:2]:
35 self.assertRegex(line, rb"^\d+$", "mode %s line %r" % (mode, line)) 36 self.assertRegex(line, rb"^\d+$", "mode %s line %r" % (mode, line))
36 37
37 def test_the_busy_mode_is_advertised_but_not_built(self): 38 def test_the_busy_mode_is_gone(self):
38 """TASK_STATS_BUSY_NETWORKS is in the enum and in the mode table, and 39 """It was listed in the mode table and implemented nowhere, so it only
39 no switch anywhere handles it, so the worker frees the empty answer 40 ever answered 500. Removed rather than written: picking poisoning
40 and the request ends as a server error. Pinned rather than blessed: 41 networks out stopped being meaningful once consumer lines became bot
41 either the report gets written or the mode leaves the table.""" 42 hosts and v6 made the address space too large to watch."""
42 with self.assertRaises(urllib.error.HTTPError) as caught: 43 with self.assertRaises(urllib.error.HTTPError) as caught:
43 self.tracker.stats("busy") 44 self.tracker.stats("busy")
44 self.assertEqual(caught.exception.code, 500) 45 self.assertEqual(caught.exception.code, 400, "an unknown mode, like any other")
45 46
46 def test_an_unknown_mode_is_refused(self): 47 def test_an_unknown_mode_is_refused(self):
47 with self.assertRaises(urllib.error.HTTPError) as caught: 48 with self.assertRaises(urllib.error.HTTPError) as caught:
@@ -131,15 +132,37 @@ class StatsTop(TrackerTestCase):
131 132
132 133
133class StatsNetworks(TrackerTestCase): 134class StatsNetworks(TrackerTestCase):
134 def test_the_slash24_report_names_the_loopback_network(self): 135 def source_address(self):
136 """Which address the tracker sees us from is not ours to choose: a
137 FreeBSD jail relocates a loopback connection to the jail's own
138 address, so ask the socket rather than assume 127.0.0.1."""
139 probe = socket.create_connection(("127.0.0.1", self.tracker.port), timeout=5)
140 try:
141 return probe.getsockname()[0]
142 finally:
143 probe.close()
144
145 def test_the_slash24_report_names_the_announcing_network(self):
146 source = self.source_address()
135 self.tracker.announce(self.hash_for("s24"), port=6001, left=0) 147 self.tracker.announce(self.hash_for("s24"), port=6001, left=0)
136 body = self.tracker.stats("s24s") 148 body = self.tracker.stats("s24s")
137 self.assertIn(b"Networks, limit /28:", body) 149 self.assertIn(b"Networks, limit /28:", body)
138 self.assertIn(b"Networks, limit /24:", body) 150 self.assertIn(b"Networks, limit /24:", body)
139 # The prefix is masked out of a v4 peer's four address bytes and then 151
140 # printed through the v6 formatter, so 127.0.0.0/24 reads as "7f00::" 152 # The prefix is the first three address bytes of a v4 peer, left
141 # rather than as an address anyone would recognise. Pinned as it is. 153 # aligned in a sixteen byte buffer and then printed by the v6
142 self.assertIn(b"7f00::", body) 154 # formatter, so 127.0.0.0/24 reads as "7f00::" rather than as the
155 # network an operator asked about. Pinned as it is, not blessed.
156 prefix = socket.inet_aton(source)[:3] + b"\0" * 13
157 self.assertIn(str(ipaddress.IPv6Address(prefix)).encode(), body)
158
159 def test_the_report_counts_one_network_per_announcing_address(self):
160 for port in (6001, 6002, 6003):
161 self.tracker.announce(self.hash_for("s24count"), port=port, left=0)
162 lines = [line for line in self.tracker.stats("s24s").splitlines() if b":" in line and b"limit" not in line]
163 self.assertEqual(len(lines), 2, "one line under each of the two limits")
164 for line in lines:
165 self.assertEqual(int(line.split(b":")[0]), 3, "three peers from the one network")
143 166
144 167
145if __name__ == "__main__": 168if __name__ == "__main__":
diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py
index 1dfa21b..1cb2389 100644
--- a/tests/integration/test_udp.py
+++ b/tests/integration/test_udp.py
@@ -149,5 +149,20 @@ class UdpScrape(TrackerTestCase):
149 sock.close() 149 sock.close()
150 150
151 151
152class UdpWorkersConfiguredAfterTheListener(TrackerTestCase):
153 """The harness writes its listen lines first, so this directive lands
154 below them: the position a config file should not care about."""
155
156 config = "listen.udp.workers 4\n"
157
158 def test_the_tracker_starts_and_still_answers_udp(self):
159 sock, connection_id = self.tracker.udp_connect()
160 try:
161 answer = self.tracker.udp_announce(sock, connection_id, self.hash_for("udpworkers"), left=0)
162 self.assertEqual(answer["seeders"], 1)
163 finally:
164 sock.close()
165
166
152if __name__ == "__main__": 167if __name__ == "__main__":
153 unittest.main() 168 unittest.main()