diff options
| -rw-r--r-- | Makefile | 6 | ||||
| -rw-r--r-- | opentracker.c | 17 | ||||
| -rw-r--r-- | ot_http.c | 40 | ||||
| -rw-r--r-- | ot_http.h | 3 | ||||
| -rw-r--r-- | ot_mutex.h | 1 | ||||
| -rw-r--r-- | ot_udp.c | 17 | ||||
| -rw-r--r-- | tests/integration/harness.py | 78 | ||||
| -rw-r--r-- | tests/integration/test_accesslist.py | 16 | ||||
| -rw-r--r-- | tests/integration/test_fullscrape.py | 32 | ||||
| -rw-r--r-- | tests/integration/test_keepalive.py | 111 | ||||
| -rw-r--r-- | tests/integration/test_stats.py | 49 | ||||
| -rw-r--r-- | tests/integration/test_udp.py | 15 |
12 files changed, 341 insertions, 44 deletions
| @@ -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. |
| 154 | PYTHON?=python3 | 154 | PYTHON?=python3 |
| 155 | FEATURES_integration=$(FEATURES) -DWANT_IP_FROM_QUERY_STRING -DWANT_ACCESSLIST_BLACK | 155 | FEATURES_integration=$(FEATURES) -DWANT_IP_FROM_QUERY_STRING -DWANT_ACCESSLIST_BLACK -DWANT_KEEPALIVE |
| 156 | CFLAGS_integration=$(CFLAGS) $(OPTS_debug) $(FEATURES_integration) -fsanitize=address,undefined -fno-omit-frame-pointer | 156 | CFLAGS_integration=$(CFLAGS) $(OPTS_debug) $(FEATURES_integration) -fsanitize=address,undefined -fno-omit-frame-pointer |
| 157 | LDFLAGS_integration=$(LDFLAGS) -fsanitize=address,undefined | 157 | LDFLAGS_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 | ||
| 173 | BINARIES=opentracker opentracker.debug opentracker.integration proxy proxy.debug $(TESTBIN) $(TESTBIN).sanitize | ||
| 174 | |||
| 173 | clean: | 175 | clean: |
| 174 | rm -rf opentracker opentracker.debug opentracker.integration proxy proxy.debug $(TESTBIN) $(TESTBIN).sanitize *.o *~ | 176 | rm -rf $(BINARIES) $(BINARIES:=.dSYM) *.o *~ |
| 175 | 177 | ||
| 176 | install: | 178 | install: |
| 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 | ||
| 179 | static void handle_write(const int64_t sock) { | 179 | static 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 | ||
| @@ -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 | ||
| 421 | static int compare_hash(const void *left, const void *right) { return memcmp(left, right, sizeof(ot_hash)); } | ||
| 422 | |||
| 421 | static ssize_t http_handle_scrape(const int64_t sock, struct ot_workstruct *ws, char *read_ptr) { | 423 | static 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; |
| @@ -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 | ||
| 27 | struct http_data { | 28 | struct http_data { |
| @@ -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, |
| @@ -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 | ||
| 219 | class 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 | |||
| 215 | class TrackerTestCase(unittest.TestCase): | 286 | class 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 | ||
| 113 | if __name__ == "__main__": | 113 | if __name__ == "__main__": |
| 114 | unittest.main() | 114 | unittest.main() |
| 115 | |||
| 116 | |||
| 117 | class 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 | |||
| 3 | The reply path has two shapes. A small answer goes out in one write and the | ||
| 4 | connection is done with immediately. A large one is queued and finishes on a | ||
| 5 | later pass through the event loop, by which time the workstruct that served | ||
| 6 | the request has moved on, so whether to keep the connection has to be | ||
| 7 | remembered on the connection itself. | ||
| 8 | """ | ||
| 9 | |||
| 10 | import unittest | ||
| 11 | import urllib.parse | ||
| 12 | |||
| 13 | from harness import TrackerTestCase, bdecode | ||
| 14 | |||
| 15 | |||
| 16 | class 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 | |||
| 110 | if __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 | |||
| 5 | the thing it counts happens. | 5 | the thing it counts happens. |
| 6 | """ | 6 | """ |
| 7 | 7 | ||
| 8 | import ipaddress | ||
| 8 | import re | 9 | import re |
| 10 | import socket | ||
| 9 | import unittest | 11 | import unittest |
| 10 | import urllib.error | 12 | import urllib.error |
| 11 | import xml.etree.ElementTree | 13 | import 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. | ||
| 19 | MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"] | 20 | MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"] |
| 20 | TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"] | 21 | TEXT_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 | ||
| 133 | class StatsNetworks(TrackerTestCase): | 134 | class 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 | ||
| 145 | if __name__ == "__main__": | 168 | if __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 | ||
| 152 | class 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 | |||
| 152 | if __name__ == "__main__": | 167 | if __name__ == "__main__": |
| 153 | unittest.main() | 168 | unittest.main() |
