Files
dragonx/qa/fd-guard/repro/tools/tcpchurn.py
T
DanSandClaude Opus 5.5 7131b3673f qa: add fd-guard, the close() guard and regtest harness that found the double close
shim/ is an LD_PRELOAD guard around close/fclose/dup2/dup3:
- before each close it resolves /proc/self/fd/N;
- it reports, with a backtrace, a non-owner close of wallet.dat,
  database/log.* or blocks/*.dat, and every close that fails with EBADF
  (the second half of a double close);
- FDGUARD_ABORT=1 aborts on the first hit;
- run_tests.sh self-tests it (51 checks).

repro/ runs a shimmed dragonxd on isolated regtest. Each run gets its
own unprivileged network namespace (loopback only). The loads are:
- rpc: a hammer with impatient/RST/half-open clients, work-queue
  bursts and long-polls;
- p2p: TLS peers churned by disconnectnode, ban and kill, plus a raw
  TCP/TLS misbehaver;
- tor: a fake control port;
- mine: mining with blocknotify and autoshield.
tools/tally.py groups the reports by closer. tools/scan_bdb.py finds BDB
pages written into block files.

With the wolfSSL CloseSocket macro in effect:
- p2pmin (300 s) gives one EBADF double close per disconnect;
- the full load (1200 s) gives 930 EBADF closes, 186 non-owner closes
  (37 of wallet/log/blk files) and a 632 KB db.log with BDB0060 PANIC;
- RPC-only and Tor-only loads give none.
After the fix, every count is 0 and db.log stays empty. The README has
the commands and the numbers.

Paths come from the environment (GOLDEN, BIN, RUNS, SHIM, ASMAP and the
Sapling params). Run output goes to $RUNS, outside the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:23:01 -05:00

148 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Raw TCP / TLS client churn against the target node's P2P port.
dragonxd P2P is TLS 1.3 only (wolfSSL), self-signed, no cert verification. The
accept path is net.cpp CreateNodeFromAcceptedSocket -> tlsmanager.accept()
(wolfSSL_accept), and teardown is CloseSocketDisconnect / ~CNode
(wolfSSL_shutdown + wolfSSL_free + CloseSocket). This driver pokes the accept and
TLS-teardown/error paths (SSL_read/SSL_write at net.cpp ~1005 and tlsmanager.cpp
~660-680) by opening many short-lived connections that each misbehave:
rst : connect, then abort with RST (SO_LINGER 0) before any TLS bytes
fin : connect, then close cleanly (FIN) before any TLS bytes
garbage : connect, send non-ClientHello bytes, then drop
stall : connect, send a few bytes, hold mid-handshake, drop
tlsjunk : complete a real TLS 1.3 handshake (verify off), send junk, drop
tlshalf : start TLS, send a partial ClientHello record, drop
Loopback only, against a port this harness started. --duration seconds.
"""
import argparse, random, socket, ssl, struct, sys, time, os
def mk():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(3.0)
return s
def rst_close(s):
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
except Exception:
pass
try: s.close()
except Exception: pass
def do_rst(host, port):
s = mk()
try:
s.connect((host, port)); rst_close(s)
except Exception:
rst_close(s)
def do_fin(host, port):
s = mk()
try:
s.connect((host, port)); s.close()
except Exception:
try: s.close()
except Exception: pass
def do_garbage(host, port):
s = mk()
try:
s.connect((host, port))
s.sendall(os.urandom(random.randint(16, 400)))
time.sleep(random.uniform(0.0, 0.3))
except Exception:
pass
rst_close(s)
def do_stall(host, port):
s = mk()
try:
s.connect((host, port))
# a couple of bytes that look like the start of a TLS record, then wait
s.sendall(b"\x16\x03\x01")
time.sleep(random.uniform(0.5, 2.0))
except Exception:
pass
rst_close(s)
def _ctx():
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
except Exception:
pass
return ctx
def do_tlsjunk(host, port):
raw = mk()
try:
raw.connect((host, port))
tls = _ctx().wrap_socket(raw, server_hostname=None)
# handshake completed; the node now expects a P2P version message.
# send junk inside the TLS channel, then tear down.
try:
tls.sendall(os.urandom(random.randint(8, 128)))
except Exception:
pass
time.sleep(random.uniform(0.0, 0.4))
try:
tls.close()
except Exception:
rst_close(raw)
except Exception:
rst_close(raw)
def do_tlshalf(host, port):
s = mk()
try:
s.connect((host, port))
# a well-formed TLS record header claiming a long handshake, but we send
# only part of it, then drop -> server sits in wolfSSL_accept WANT_READ.
body = os.urandom(40)
rec = b"\x16\x03\x03" + struct.pack(">H", 512) + body
s.sendall(rec)
time.sleep(random.uniform(0.3, 1.5))
except Exception:
pass
rst_close(s)
HANDLERS = [do_rst, do_fin, do_garbage, do_stall, do_tlsjunk, do_tlshalf]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, required=True)
ap.add_argument("--duration", type=float, default=300.0)
ap.add_argument("--concurrency", type=int, default=6)
ap.add_argument("--rate", type=float, default=0.03, help="sleep between bursts")
args = ap.parse_args()
end = time.time() + args.duration
counts = {h.__name__: 0 for h in HANDLERS}
import threading
lock = threading.Lock()
def worker():
while time.time() < end:
h = random.choice(HANDLERS)
h(args.host, args.port)
with lock:
counts[h.__name__] += 1
time.sleep(random.uniform(0.0, args.rate))
ts = [threading.Thread(target=worker, daemon=True) for _ in range(args.concurrency)]
for t in ts: t.start()
for t in ts: t.join()
total = sum(counts.values())
print(f"tcpchurn: {total} connections in {args.duration:.0f}s -> {counts}", flush=True)
if __name__ == "__main__":
main()