3proxy/tests/cases/udploop.py
Vladimir Dubrovin 83d8138818 Tests: udploop's silence counted from the last answer
The last datagram's time was taken after the sleep following it, half a
second and a slow runner's overshoot late, so a session ending on time
looked early. The test resolver no longer raises when its socket is
closed under it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:56:38 +03:00

376 lines
14 KiB
Python

"""UDP services in one thread: udppm sessions, many at once, their limits and
deadlines, and their adoption by the new service on a reload; dnspr
forwarding to a name server without holding up other queries."""
import socket
import struct
import threading
import time
def exchange(sock, port, payload, timeout=2.0):
sock.settimeout(timeout)
sock.sendto(payload, ("127.0.0.1", port))
try:
return sock.recvfrom(65536)[0]
except OSError:
return None
def dns_raw(name, qtype, qid=0x3B3B):
q = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0)
for label in name.split("."):
q += bytes([len(label)]) + label.encode()
return q + b"\x00" + struct.pack("!HH", qtype, 1)
def dns_reply(data, answer):
"""The answer to a query: an A record 10.9.8.7 for an A query, else a TXT
record with the text given."""
reply = bytearray(data)
reply[2] = 0x81
reply[3] = 0x80
reply[6:8] = struct.pack("!H", 1)
pos = 12
while data[pos]:
pos += data[pos] + 1
qtype = struct.unpack("!H", data[pos + 1:pos + 3])[0]
if qtype == 1:
reply += b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 60, 4) + bytes([10, 9, 8, 7])
else:
txt = bytes([len(answer)]) + answer
reply += b"\xc0\x0c" + struct.pack("!HHIH", 16, 1, 60, len(txt)) + txt
return bytes(reply)
def fake_name_server(answer, tcp=False, wrongid=False):
"""A name server answering every query (see dns_reply), over UDP or
TCP; with answer None, one which answers nothing."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM if tcp else socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
def serve_tcp(c):
try:
c.settimeout(10)
head = c.recv(2)
n = struct.unpack("!H", head)[0]
data = b""
while len(data) < n:
data += c.recv(n - len(data))
reply = dns_reply(data, answer)
# in two pieces, as a stream may bring it
c.sendall(struct.pack("!H", len(reply)) + reply[:5])
time.sleep(0.05)
c.sendall(reply[5:])
except OSError:
pass
c.close()
def serve():
while True:
try:
if tcp:
c, _ = sock.accept()
threading.Thread(target=serve_tcp, args=(c,), daemon=True).start()
continue
data, peer = sock.recvfrom(65536)
except OSError:
return
if answer is not None:
reply = dns_reply(data, answer)
if wrongid:
reply = bytes([reply[0] ^ 0x55, reply[1]]) + reply[2:]
try:
sock.sendto(reply, peer)
except OSError:
# closed once the case is done with it
return
if tcp:
sock.listen(8)
threading.Thread(target=serve, daemon=True).start()
return sock, sock.getsockname()[1]
def run(t):
echo = t.udp_echo()
adm = t.free_port()
many, single, idle, limited, acl = (t.free_port() for _ in range(5))
def config(acl_line):
return f"""
log
auth iponly
allow *
admin -p{adm}
flush
auth iponly
allow *
udppm {many} 127.0.0.1 {echo}
udppm -s {single} 127.0.0.1 {echo}
flush
timeouts 1 5 30 2 180 1800 15 60 15 5 5
auth iponly
allow *
udppm {idle} 127.0.0.1 {echo}
flush
maxconn 2
auth iponly
allow *
udppm {limited} 127.0.0.1 {echo}
flush
auth iponly
{acl_line}
udppm {acl} 127.0.0.1 {echo}
"""
server = t.start("udploop", config("allow *"), ports=[adm])
for p in (many, single, idle, limited, acl):
t.wait_udp(p)
# --- many sessions at once, each answered with its own datagrams ------
clients = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(20)]
wrong = 0
for rnd in range(3):
for i, c in enumerate(clients):
c.sendto(f"c{i}r{rnd}".encode(), ("127.0.0.1", many))
for i, c in enumerate(clients):
c.settimeout(2)
try:
got = c.recvfrom(2048)[0]
except OSError:
got = None
if got != f"echo:c{i}r{rnd}".encode():
wrong += 1
for c in clients:
c.close()
t.eq(0, wrong, "20 clients at once each get their own answers, three rounds")
# --- -s: a session ends with its first answer -------------------------
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
t.eq(b"echo:one", exchange(s, single, b"one"), "with -s a datagram is answered")
t.eq(b"echo:two", exchange(s, single, b"two"), "and the next one too, in a new session")
cport = s.getsockname()[1]
s.close()
time.sleep(0.3)
ended = [l for l in server.output().splitlines()
if f" {single} 00000 " in l and f":{cport} " in l]
t.eq(2, len(ended), "each answer ended its session, logged")
# --- idle timeout: STRING_LONG of silence ends a session (92) ----------
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
t.eq(b"echo:x", exchange(s, idle, b"x"), "a session is answered")
cport = s.getsockname()[1]
def idle_ended():
return any(f" {idle} 00092 " in l and f":{cport} " in l for l in server.output().splitlines())
deadline = time.time() + 6
while time.time() < deadline and not idle_ended():
time.sleep(0.2)
t.ok("two seconds of silence end it, logged with 92") if idle_ended() else \
t.fail("two seconds of silence end it, logged with 92", "a 00092 line", "none")
s.close()
# --- a session heard from lives on past its first deadline, and ends
# its idle time after the last datagram, neither sooner nor much later
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
answered = 0
for i in range(10):
if i:
time.sleep(0.5)
if exchange(s, idle, f"k{i}".encode()) == f"echo:k{i}".encode():
answered += 1
# the silence counts from the last answer, not a sleep after it
last = time.time()
cport = s.getsockname()[1]
t.eq(10, answered, "a datagram every half second for five seconds is answered")
t.ok("and the session lives on through them") if not idle_ended() else \
t.fail("and the session lives on through them", "no 00092 line", "ended")
while time.time() < last + 6 and not idle_ended():
time.sleep(0.1)
took = time.time() - last
if idle_ended() and 1.5 <= took <= 3.5:
t.ok("then two seconds of silence end it")
else:
t.fail("then two seconds of silence end it", "ended 1.5-3.5 s after the last",
f"{'ended' if idle_ended() else 'not ended'} after {took:.1f} s")
s.close()
# --- maxconn: a client without a free slot is not answered -------------
held = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(2)]
t.eq([b"echo:a", b"echo:a"], [exchange(h, limited, b"a") for h in held],
"two clients take the two slots")
third = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
t.eq(None, exchange(third, limited, b"b", timeout=1),
"a third gets no answer while they hold them")
t.contains(server.output(), "too many connected clients",
"and that is logged")
for h in held + [third]:
h.close()
# --- reload: sessions are adopted and re-authenticated -----------------
kept = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
t.eq(b"echo:k1", exchange(kept, acl, b"k1"), "a session before the reload")
mark = len(server.output())
t.write_config("udploop", config("deny *"))
t.http(f"http://127.0.0.1:{adm}/R")
time.sleep(2.5)
t.eq(None, exchange(kept, acl, b"k2", timeout=1),
"denied by the new rules it is answered no more")
cport = kept.getsockname()[1]
t.ok("its session ended by the re-authentication") \
if any(f" {acl} " in l and f":{cport} " in l and " 00000 " not in l
for l in server.output()[mark:].splitlines()) else \
t.fail("its session ended by the re-authentication", "a line with a result", "none")
kept.close()
t.write_config("udploop", config("allow *"))
t.http(f"http://127.0.0.1:{adm}/R")
time.sleep(2.5)
# --- dnspr: forwarded queries wait in the loop, not in it ---------------
good, gport = fake_name_server(b"from-the-server")
mute, mport = fake_name_server(None)
fwd, slow, local = t.free_port(), t.free_port(), t.free_port()
t.start("udploop_dns", f"""
log
timeouts 1 5 30 60 180 1800 3 60 15 5 5
nserver 127.0.0.1:{gport}
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{fwd}
flush
auth iponly
allow *
dnspr -p{local}
""")
# a service of its own whose name server says nothing
dns2 = t.start("udploop_mute", f"""
log
timeouts 1 5 30 60 180 1800 3 60 15 5 5
nserver 127.0.0.1:{mport}
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{slow}
""")
t.wait_udp(fwd, payload=dns_raw("local.test", 1))
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
got = exchange(s, fwd, dns_raw("txt.test", 16))
t.ok("a TXT query is sent on to the name server and its answer relayed") \
if got and b"from-the-server" in got else \
t.fail("a TXT query is sent on to the name server and its answer relayed", "the TXT", got)
# an address not in the cache is asked of the name server, and kept
got = exchange(s, fwd, dns_raw("a.test", 1))
t.ok("an A query not in the cache is answered by the name server") \
if got and bytes([10, 9, 8, 7]) in got else \
t.fail("an A query not in the cache is answered by the name server", "10.9.8.7", got)
good.close()
time.sleep(0.2)
got = exchange(s, fwd, dns_raw("a.test", 1))
t.ok("and then from the name cache, the name server gone") \
if got and bytes([10, 9, 8, 7]) in got else \
t.fail("and then from the name cache, the name server gone", "10.9.8.7", got)
t.wait_udp(slow, payload=dns_raw("local.test", 1))
pending = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# an A query, which the resolver used to wait for in place
pending.sendto(dns_raw("mute.test", 1), ("127.0.0.1", slow))
start = time.time()
other = exchange(s, slow, dns_raw("local.test", 1), timeout=2)
t.ok(f"while one query waits for a silent name server another is answered ({time.time() - start:.2f} s)") \
if other and b"\x0a\x01\x02\x03" in other else \
t.fail("while one query waits for a silent name server another is answered", "10.1.2.3", other)
deadline = time.time() + 6
while time.time() < deadline and " 00821 " not in dns2.output():
time.sleep(0.2)
t.contains(dns2.output(), " 00821 ", "the waiting one ends at DNS_TO (821)")
# a name server over TCP; and a second one asked when the first is silent
tcpns, tport = fake_name_server(b"over-tcp", tcp=True)
second, sport = fake_name_server(b"the-second")
viatcp, fallback = t.free_port(), t.free_port()
t.start("udploop_tcpns", f"""
log
timeouts 1 5 30 60 180 1800 2 60 15 5 5
nserver 127.0.0.1:{tport}/tcp
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{viatcp}
""")
t.start("udploop_fallback", f"""
log
timeouts 1 5 30 60 180 1800 2 60 15 5 5
nserver 127.0.0.1:{mport}
nserver 127.0.0.1:{sport}
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{fallback}
""")
t.wait_udp(viatcp, payload=dns_raw("local.test", 1))
got = exchange(s, viatcp, dns_raw("tcp.test", 16), timeout=4)
t.ok("a name server over TCP is asked, its answer in pieces put together") \
if got and b"over-tcp" in got else \
t.fail("a name server over TCP is asked, its answer in pieces put together", "the TXT", got)
t.wait_udp(fallback, payload=dns_raw("local.test", 1))
got = exchange(s, fallback, dns_raw("second.test", 16), timeout=6)
t.ok("the first name server silent, the second is asked after DNS_TO") \
if got and b"the-second" in got else \
t.fail("the first name server silent, the second is asked after DNS_TO", "the TXT", got)
# an answer with another id is not the answer: neither relayed nor kept
liar, lport = fake_name_server(b"forged", wrongid=True)
guarded = t.free_port()
t.start("udploop_liar", f"""
log
timeouts 1 5 30 60 180 1800 2 60 15 5 5
nserver 127.0.0.1:{lport}
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{guarded}
""")
t.wait_udp(guarded, payload=dns_raw("local.test", 1))
t.eq(None, exchange(s, guarded, dns_raw("forged.test", 1), timeout=3),
"an answer with another transaction id is not relayed")
t.eq(None, exchange(s, guarded, dns_raw("forged.test", 1), timeout=3),
"nor kept in the name cache")
# a label with a '.' in it is another name than its text reads: answered,
# but not kept under that text
caching, cport2 = fake_name_server(b"x")
dotted = t.free_port()
t.start("udploop_dotted", f"""
log
nserver 127.0.0.1:{cport2}
nscache 256
nsrecord local.test 10.1.2.3
auth iponly
allow *
dnspr -p{dotted}
""")
t.wait_udp(dotted, payload=dns_raw("local.test", 1))
odd = struct.pack("!HHHHHH", 0x4C4C, 0x0100, 1, 0, 0, 0) + b"\x08evil.dot\x04test\x00" + struct.pack("!HH", 1, 1)
got = exchange(s, dotted, odd)
t.ok("a name with a '.' inside a label is answered") \
if got and bytes([10, 9, 8, 7]) in got else t.fail("a name with a '.' inside a label is answered", "10.9.8.7", got)
caching.close()
time.sleep(0.2)
t.eq(None, exchange(s, dotted, dns_raw("evil.dot.test", 1), timeout=3),
"and the name its text reads as was not cached from it")
for x in (s, pending, mute, tcpns, second, liar):
x.close()