mirror of
https://github.com/3proxy/3proxy.git
synced 2026-10-28 22:05:48 +08:00
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>
376 lines
14 KiB
Python
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()
|