3proxy/tests/cases/udploop.py
Vladimir Dubrovin 58334cb51c dnspr: name servers asked from the loop, nothing waits in it
dnspr answered an A or AAAA query missing from the name cache with
udpresolve(), which waited for the name servers in place, and a name
server over TCP was waited for in place too, holding up every query of
the service. Now only the name cache is looked at in place; anything it
does not have goes to the name servers from the loop: over UDP the query
is sent on a socket the loop waits on, over TCP the socket is connected
without waiting, the query written once the loop sees it writable, and
the answer read in as many pieces as it comes. A name server silent for
DNS_TO is followed by the next one, as udpresolve() did; the last ends
the query with 821. An address in an answer is kept in the name cache,
unless -s says not to use it.

The UDP loop lets a session say what it waits for on its socket
(events) and a service handle a session's deadline itself (timeout).

Tests: an A query not in the cache answered by the name server and then
from the cache with the server gone, an A query waiting for a silent
name server while another is answered, a name server over TCP whose
answer comes in pieces, and the second name server asked when the
first is silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:50:34 +03:00

303 lines
11 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):
"""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:
sock.sendto(dns_reply(data, answer), peer)
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()
# --- 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)
for x in (s, pending, mute, tcpns, second):
x.close()