summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Mk/tests.mk20
-rw-r--r--tests/integration/harness.py34
-rw-r--r--tests/integration/test_livesync.py121
3 files changed, 172 insertions, 3 deletions
diff --git a/Mk/tests.mk b/Mk/tests.mk
index 46f308b..a7d4fbc 100644
--- a/Mk/tests.mk
+++ b/Mk/tests.mk
@@ -29,7 +29,13 @@ LDFLAGS_integration=$(LINK) -fsanitize=address,undefined
29CFLAGS_tsan=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_integration) -fsanitize=thread -fno-omit-frame-pointer 29CFLAGS_tsan=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_integration) -fsanitize=thread -fno-omit-frame-pointer
30LDFLAGS_tsan=$(LINK) -fsanitize=thread 30LDFLAGS_tsan=$(LINK) -fsanitize=thread
31 31
32BINARIES+=$(TESTBIN) $(TESTBIN).poll $(TESTBIN).sanitize opentracker.integration opentracker.tsan 32# Live sync is a compile time feature, so reaching it from a black box test
33# needs a binary of its own. tests/integration/test_livesync.py skips itself
34# against a build without it, which is what -v is for.
35FEATURES_livesync=$(FEATURES_integration) -DWANT_SYNC_LIVE
36CFLAGS_livesync=$(OPTS_debug) $(CPPFLAGS) $(OT_CFLAGS) $(FEATURES_livesync) -fsanitize=address,undefined -fno-omit-frame-pointer
37
38BINARIES+=$(TESTBIN) $(TESTBIN).poll $(TESTBIN).sanitize opentracker.integration opentracker.tsan opentracker.livesync
33 39
34$(TESTBIN): $(TEST_SOURCES) $(TEST_UNITS) $(TEST_HEADERS) $(BUILDDEPS) 40$(TESTBIN): $(TEST_SOURCES) $(TEST_UNITS) $(TEST_HEADERS) $(BUILDDEPS)
35 $(CC) -o $@ $(CFLAGS_test) $(TEST_SOURCES) $(TEST_UNITS) 41 $(CC) -o $@ $(CFLAGS_test) $(TEST_SOURCES) $(TEST_UNITS)
@@ -46,6 +52,9 @@ opentracker.integration: $(SOURCES) $(HEADERS) $(BUILDDEPS)
46opentracker.tsan: $(SOURCES) $(HEADERS) $(BUILDDEPS) 52opentracker.tsan: $(SOURCES) $(HEADERS) $(BUILDDEPS)
47 $(CC) -o $@ $(CFLAGS_tsan) $(SOURCES) $(LDFLAGS_tsan) 53 $(CC) -o $@ $(CFLAGS_tsan) $(SOURCES) $(LDFLAGS_tsan)
48 54
55opentracker.livesync: $(SOURCES) $(HEADERS) $(BUILDDEPS)
56 $(CC) -o $@ $(CFLAGS_livesync) $(SOURCES) $(LDFLAGS_integration)
57
49check: $(TESTBIN) $(BINARY) 58check: $(TESTBIN) $(BINARY)
50 ./$(TESTBIN) 59 ./$(TESTBIN)
51 60
@@ -61,12 +70,19 @@ check-integration: opentracker.integration
61check-tsan: opentracker.tsan 70check-tsan: opentracker.tsan
62 OPENTRACKER=./opentracker.tsan $(PYTHON) -m unittest discover -s tests/integration 71 OPENTRACKER=./opentracker.tsan $(PYTHON) -m unittest discover -s tests/integration
63 72
73# Only the live sync tests: a build with WANT_SYNC_LIVE refuses to start
74# without a live sync address, so it cannot stand in for the plain binary in
75# the rest of the suite.
76check-livesync: opentracker.livesync
77 OPENTRACKER=./opentracker.livesync $(PYTHON) -m unittest discover -s tests/integration -p 'test_livesync.py'
78
64# Cheapest first, so a quick failure comes back quickly. 79# Cheapest first, so a quick failure comes back quickly.
65check-all: 80check-all:
66 $(MAKE) check 81 $(MAKE) check
67 $(MAKE) check-poll 82 $(MAKE) check-poll
68 $(MAKE) check-sanitize 83 $(MAKE) check-sanitize
69 $(MAKE) check-integration 84 $(MAKE) check-integration
85 $(MAKE) check-livesync
70 $(MAKE) check-tsan 86 $(MAKE) check-tsan
71 87
72.PHONY: check check-poll check-sanitize check-integration check-tsan check-all 88.PHONY: check check-poll check-sanitize check-integration check-livesync check-tsan check-all
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
index b98ce83..a9d38ca 100644
--- a/tests/integration/harness.py
+++ b/tests/integration/harness.py
@@ -20,6 +20,9 @@ import urllib.error
20import urllib.parse 20import urllib.parse
21import urllib.request 21import urllib.request
22 22
23# groupip_1 in src/ot_livesync.c
24LIVESYNC_GROUP = "224.0.23.5"
25
23TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug")) 26TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug"))
24 27
25 28
@@ -76,6 +79,9 @@ class Tracker:
76 79
77 def __init__(self, extra_config="", files=None): 80 def __init__(self, extra_config="", files=None):
78 self.port = free_port() 81 self.port = free_port()
82 # Only used by a config that asks for live sync, but allocated either
83 # way so {livesync_port} always resolves.
84 self.livesync_port = free_port()
79 self.dir = tempfile.mkdtemp(prefix="ot-integration-") 85 self.dir = tempfile.mkdtemp(prefix="ot-integration-")
80 self.log = None 86 self.log = None
81 self.process = None 87 self.process = None
@@ -87,7 +93,7 @@ class Tracker:
87 with open(self.config, "w") as handle: 93 with open(self.config, "w") as handle:
88 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port) 94 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port)
89 handle.write("listen.udp 127.0.0.1:%d\n" % self.port) 95 handle.write("listen.udp 127.0.0.1:%d\n" % self.port)
90 handle.write(extra_config.replace("{dir}", self.dir)) 96 handle.write(extra_config.replace("{dir}", self.dir).replace("{livesync_port}", str(self.livesync_port)))
91 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b") 97 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b")
92 # Leak detection is left at the sanitizer's own default, which is on 98 # Leak detection is left at the sanitizer's own default, which is on
93 # where LeakSanitizer exists and off where it does not. Asking for it 99 # where LeakSanitizer exists and off where it does not. Asking for it
@@ -186,6 +192,32 @@ class Tracker:
186 def torrent_count(self): 192 def torrent_count(self):
187 return int(self.stats("torr").split()[0]) 193 return int(self.stats("torr").split()[0])
188 194
195 def livesync_send(self, payload):
196 """Put a packet on the live sync group.
197
198 It has to go to the group rather than straight at the port: the
199 tracker binds two sockets there, the reader on the wildcard address
200 and the writer on the configured one, and the kernel gives a unicast
201 datagram to the more specific of the two, which nobody reads."""
202 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
203 try:
204 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton("127.0.0.1"))
205 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
206 sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 1)
207 sock.sendto(payload, (LIVESYNC_GROUP, self.livesync_port))
208 finally:
209 sock.close()
210
211 def wait_until(self, predicate, timeout=5):
212 """A live sync packet is handled by another thread, so an effect of
213 one arrives some time after the send."""
214 deadline = time.time() + timeout
215 while time.time() < deadline:
216 if predicate():
217 return True
218 time.sleep(0.02)
219 return predicate()
220
189 # ------------------------------------------------------------ over udp 221 # ------------------------------------------------------------ over udp
190 222
191 def udp_connect(self): 223 def udp_connect(self):
diff --git a/tests/integration/test_livesync.py b/tests/integration/test_livesync.py
new file mode 100644
index 0000000..8dd754b
--- /dev/null
+++ b/tests/integration/test_livesync.py
@@ -0,0 +1,121 @@
1"""Live sync, driven over the multicast group a cluster would use.
2
3Live sync sits behind WANT_SYNC_LIVE, so these only run against a binary
4built with it: make check-livesync. Against any other build they skip, which
5is what the feature list in -v is for.
6"""
7
8import socket
9import struct
10import subprocess
11import unittest
12
13from harness import TRACKER, TrackerTestCase
14
15# enum { OT_SYNC_PEER4, OT_SYNC_PEER6 } in src/ot_livesync.c
16SYNC_PEER4 = 0
17SYNC_PEER6 = 1
18
19# src/trackerlogic.h
20FLAG_SEEDING = 0x80
21FLAG_COMPLETED = 0x40
22FLAG_STOPPED = 0x20
23
24
25def has_livesync():
26 done = subprocess.run([TRACKER, "-v"], capture_output=True, timeout=30)
27 return b"WANT_SYNC_LIVE" in done.stderr
28
29
30def peer4(address, port, flags):
31 """Four bytes of address, two of port, one of flags, one the tracker
32 overwrites with its own idea of the time."""
33 return socket.inet_aton(address) + struct.pack("!H", port) + bytes([flags, 0])
34
35
36def peer6(address, port, flags):
37 return socket.inet_pton(socket.AF_INET6, address) + struct.pack("!H", port) + bytes([flags, 0])
38
39
40def packet(kind, entries, tracker_id=0xDEADBEEF):
41 body = b"".join(info_hash + peer for info_hash, peer in entries)
42 return struct.pack("!I", tracker_id) + struct.pack("!I", kind) + body
43
44
45@unittest.skipUnless(has_livesync(), "this build has no WANT_SYNC_LIVE")
46class LiveSyncIn(TrackerTestCase):
47 config = "livesync.cluster.listen 127.0.0.1:{livesync_port}\nlivesync.cluster.node_ip 127.0.0.1\n"
48
49 def counts(self, info_hash):
50 """Seeders and leechers a scrape reports for one torrent."""
51 files = self.tracker.scrape(info_hash)[b"files"]
52 if info_hash not in files:
53 return None
54 entry = files[info_hash]
55 return entry[b"complete"], entry[b"incomplete"]
56
57 def test_a_synced_seeder_arrives(self):
58 info_hash = self.hash_for("sync-seed")
59 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.3", 6881, FLAG_SEEDING))]))
60 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)),
61 "scrape says %r" % (self.counts(info_hash),))
62
63 def test_a_synced_leecher_arrives(self):
64 info_hash = self.hash_for("sync-leech")
65 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.4", 6881, 0))]))
66 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (0, 1)),
67 "scrape says %r" % (self.counts(info_hash),))
68
69 def test_a_v6_peer_arrives(self):
70 info_hash = self.hash_for("sync-v6")
71 self.tracker.livesync_send(packet(SYNC_PEER6, [(info_hash, peer6("2001:db8::1", 6881, FLAG_SEEDING))]))
72 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)),
73 "scrape says %r" % (self.counts(info_hash),))
74
75 def test_one_packet_carries_several_torrents(self):
76 hashes = [self.hash_for("sync-many-%d" % i) for i in range(4)]
77 self.tracker.livesync_send(packet(SYNC_PEER4, [(h, peer4("10.1.2.%d" % (5 + i), 6881, FLAG_SEEDING))
78 for i, h in enumerate(hashes)]))
79 self.assertTrue(self.tracker.wait_until(lambda: all(self.counts(h) == (1, 0) for h in hashes)))
80
81 def test_a_stopped_peer_is_taken_away_again(self):
82 info_hash = self.hash_for("sync-stop")
83 peer = peer4("10.1.2.9", 6881, FLAG_SEEDING)
84 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer)]))
85 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) == (1, 0)))
86
87 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.9", 6881, FLAG_STOPPED))]))
88 self.assertTrue(self.tracker.wait_until(lambda: self.counts(info_hash) in (None, (0, 0))),
89 "scrape still says %r" % (self.counts(info_hash),))
90
91 def test_a_truncated_entry_is_dropped(self):
92 """The loop takes whole hash and peer pairs and leaves a short tail
93 where it is, so the complete entry before it still lands."""
94 good, short = self.hash_for("sync-good"), self.hash_for("sync-short")
95 payload = packet(SYNC_PEER4, [(good, peer4("10.1.2.10", 6881, FLAG_SEEDING))]) + short + b"\x0a\x01"
96 self.tracker.livesync_send(payload)
97 self.assertTrue(self.tracker.wait_until(lambda: self.counts(good) == (1, 0)))
98 self.assertIsNone(self.counts(short))
99
100 def test_an_unknown_packet_type_is_ignored(self):
101 info_hash = self.hash_for("sync-unknown")
102 self.tracker.livesync_send(packet(99, [(info_hash, peer4("10.1.2.11", 6881, FLAG_SEEDING))]))
103 self.assertFalse(self.tracker.wait_until(lambda: self.counts(info_hash) is not None, timeout=1))
104
105 def test_a_packet_with_nothing_but_a_header_is_ignored(self):
106 before = self.tracker.torrent_count()
107 self.tracker.livesync_send(struct.pack("!I", 0xDEADBEEF) + struct.pack("!I", SYNC_PEER4))
108 self.assertFalse(self.tracker.wait_until(lambda: self.tracker.torrent_count() != before, timeout=1))
109
110
111@unittest.skipUnless(has_livesync(), "this build has no WANT_SYNC_LIVE")
112class LiveSyncAccess(TrackerTestCase):
113 """Nothing blesses 127.0.0.1 here, so the tracker should take nothing
114 from it. Source address checking is all live sync has."""
115
116 config = "livesync.cluster.listen 127.0.0.1:{livesync_port}\nlivesync.cluster.node_ip 10.0.0.1\n"
117
118 def test_an_unblessed_sender_is_ignored(self):
119 info_hash = self.hash_for("sync-unblessed")
120 self.tracker.livesync_send(packet(SYNC_PEER4, [(info_hash, peer4("10.1.2.12", 6881, FLAG_SEEDING))]))
121 self.assertFalse(self.tracker.wait_until(lambda: self.tracker.torrent_count() != 0, timeout=1))