aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/044-specs/dns_check.py
diff options
context:
space:
mode:
Diffstat (limited to 'research/experiments/044-specs/dns_check.py')
-rw-r--r--research/experiments/044-specs/dns_check.py166
1 files changed, 166 insertions, 0 deletions
diff --git a/research/experiments/044-specs/dns_check.py b/research/experiments/044-specs/dns_check.py
new file mode 100644
index 0000000..d492fda
--- /dev/null
+++ b/research/experiments/044-specs/dns_check.py
@@ -0,0 +1,166 @@
+#!/usr/bin/env python3
+"""Research 140: DNS messages (RFC 1035) written in aup (aup's examples/dns.aup).
+
+Builds it with aup (AUP, default the sibling checkout) and reads messages dnspython made (queries and responses: A,
+AAAA, NS, CNAME, PTR, MX, SOA, TXT and SRV records, EDNS, names sharing suffixes so compression points back, labels of
+63 bytes and bytes above 0x7f, names near 255 bytes) on Pure's three paths (batches, rounds, all compiled): each
+message's id, flags and records (names uncompressed) as dnspython reads them, and the message the view wire writes back
+read by dnspython as the same message. Then damaged messages (cut short, a bit flipped) against dns_message.py, which
+reads them the same way: the same records and bytes when it takes one, a failed event when it refuses it. Then counts
+lines (code lines: neither blank nor a comment; brace lines: a closing brace alone) of dns.aup and of dns_message.py.
+One JSON line. Given a path, it also writes the messages there for dns_time.c."""
+import json, os, random, subprocess, sys, tempfile
+
+import dns.flags, dns.message, dns.rrset
+
+here = os.path.dirname(os.path.abspath(__file__))
+pure = os.path.normpath(os.path.join(here, '../../..'))
+aup = os.environ.get('AUP', os.path.join(pure, '..', 'aup'))
+sys.path.insert(0, here)
+import dns_message # noqa: E402
+
+
+def lines(path, stop=None):
+ code = braces = 0
+ for line in open(path):
+ if stop and line.startswith(stop): break
+ text = line.strip()
+ if not text or text.startswith('#'): continue
+ code += 1
+ braces += text == '}'
+ return code, braces
+
+
+def messages(chooser, count):
+ def label():
+ pick = chooser.random()
+ if pick < 0.05:
+ return ''.join(chooser.choices('abcdefghij', k=63))
+ if pick < 0.1:
+ return ''.join(f'\\{chooser.randrange(128, 256):03d}' for _ in range(chooser.randrange(1, 4)))
+ return ''.join(chooser.choices('abcdefghijklmnopqrstuvwxyz0123456789-', k=chooser.randrange(1, 12)))
+
+ zones = [f'{label()}.{chooser.choice(["com", "net", "org", "kr"])}.' for _ in range(6)]
+
+ def name():
+ pick = chooser.random()
+ if pick < 0.03: # 245 bytes
+ return '.'.join(''.join(chooser.choices('xyz', k=60)) for _ in range(4)) + '.'
+ if pick < 0.06:
+ return '.'
+ return '.'.join([label() for _ in range(chooser.randrange(0, 3))] + [chooser.choice(zones)])
+
+ def rdata(kind):
+ return {'A': lambda: '.'.join(str(chooser.randrange(256)) for _ in range(4)),
+ 'AAAA': lambda: ':'.join(f'{chooser.randrange(65536):x}' for _ in range(8)),
+ 'NS': name, 'CNAME': name, 'PTR': name,
+ 'MX': lambda: f'{chooser.randrange(65536)} {name()}',
+ 'SOA': lambda: f'{name()} {name()} ' + ' '.join(str(chooser.randrange(2 ** 32)) for _ in range(5)),
+ 'TXT': lambda: ' '.join('"' + ''.join(chooser.choices('abc xyz', k=chooser.randrange(0, 40))) + '"'
+ for _ in range(chooser.randrange(1, 4))),
+ 'SRV': lambda: f'{chooser.randrange(65536)} {chooser.randrange(65536)} {chooser.randrange(65536)} {name()}'}[kind]()
+
+ kinds = ['A', 'AAAA', 'NS', 'CNAME', 'PTR', 'MX', 'SOA', 'TXT', 'SRV']
+ out = []
+ for _ in range(count):
+ q = dns.message.make_query(name(), chooser.choice(kinds), use_edns=0 if chooser.random() < 0.4 else None,
+ payload=chooser.choice([512, 1232, 4096]), id=chooser.randrange(65536))
+ if chooser.random() < 0.2:
+ out.append(q)
+ continue
+ r = dns.message.make_response(q)
+ r.flags |= chooser.choice([0, dns.flags.AA, dns.flags.TC | dns.flags.AA, dns.flags.RA])
+ r.set_rcode(chooser.choice([0, 0, 0, 2, 3, 5]))
+ ttls = {} # an RRset's records share a TTL (RFC 2181), so one name and type keep theirs
+ for section in (r.answer, r.authority, r.additional):
+ for _ in range(chooser.choice([0, 1, 1, 2, 4])):
+ kind, owner = chooser.choice(kinds), name()
+ ttl = ttls.setdefault((owner, kind), chooser.randrange(2 ** 31))
+ section.append(dns.rrset.from_text(owner, ttl, 'IN', kind, *[rdata(kind) for _ in range(chooser.randrange(1, 4))]))
+ out.append(r)
+ return out
+
+
+def seen(m):
+ """A message's id, flags and records (section, name, type, class, ttl, data) as dnspython reads it, each section's
+ records in order (dnspython gathers a section's records of one name and type into one set, so its order is not the
+ message's)."""
+ records = [(0, q.name.to_wire(), q.rdtype, q.rdclass, 0, b'') for q in m.question]
+ for section, rrsets in ((1, m.answer), (2, m.authority), (3, m.additional)):
+ for rrset in rrsets:
+ records += [(section, rrset.name.to_wire(), rrset.rdtype, rrset.rdclass, rrset.ttl, rd.to_wire()) for rd in rrset]
+ if m.opt:
+ records += [(3, b'\0', 41, m.opt.rdclass, m.opt.ttl, rd.to_wire()) for rd in m.opt]
+ return m.id, m.flags, sorted(records)
+
+
+def damaged(chooser, wires):
+ out = []
+ for wire in chooser.sample(wires, 150):
+ cut = wire[:chooser.randrange(len(wire))]
+ flipped = bytearray(wire)
+ flipped[chooser.randrange(len(wire))] ^= 1 << chooser.randrange(8)
+ for message in (cut, bytes(flipped)):
+ try:
+ id, flags, records = dns_message.read(message)
+ out.append((message, (id, flags, records, dns_message.write(id, flags, records))))
+ except (ValueError, IndexError):
+ out.append((message, None))
+ return out
+
+
+def run(module, wires, env, work):
+ given = []
+ for n, wire in enumerate(wires):
+ path = os.path.join(work, f'{n}.dns')
+ with open(path, 'wb') as f:
+ f.write(wire)
+ given.append(f'message read @{path}\nshow\n')
+ out = subprocess.run([os.path.join(pure, 'build', 'pure-run'), module], input=''.join(given), capture_output=True,
+ text=True, env=dict(os.environ, **env), timeout=1800).stdout.splitlines()
+ got = []
+ for line in out[1::2]: # each message's show
+ state = json.loads(line)
+ s = state['states']
+ records = [(r['section'], bytes.fromhex(r['name']), r['type'], r['class'], r['ttl'], bytes.fromhex(r['data']))
+ for r in s['records']]
+ got.append((s['id'], s['flags'], records, bytes.fromhex(state['views']['wire'])))
+ failed = [json.loads(line)['fault'] != 'none' for line in out[0::2]]
+ return out, got, failed
+
+
+chooser = random.Random(140)
+made = messages(chooser, 400)
+wires = [m.to_wire(max_size=65535, want_shuffle=False) for m in made] # dnspython shuffles a set's records
+if len(sys.argv) > 1: # the messages for dns_time.c: each a u16 big-endian length, then its bytes (as over TCP)
+ with open(sys.argv[1], 'wb') as f:
+ f.write(b''.join(len(w).to_bytes(2, 'big') + w for w in wires))
+broken = damaged(chooser, wires)
+paths = {}
+with tempfile.TemporaryDirectory() as work:
+ module = os.path.join(work, 'dns.pmod')
+ subprocess.run([os.path.join(aup, 'build', 'aup'), 'build', os.path.join(aup, 'examples', 'dns.aup'), '-o', module],
+ check=True)
+ outputs = {}
+ for name, env in (('batches', {'PURE_JIT': '0'}), ('rounds', {'PURE_JIT': '0', 'PURE_BATCH': '0'}),
+ ('all', {'PURE_JIT': 'all'})):
+ raw, got, failed = run(module, wires, env, work)
+ right = 0
+ for wire, g, f in zip(wires, got, failed):
+ read = dns.message.from_wire(wire)
+ right += not f and (g[0], g[1], sorted(g[2])) == seen(read) and dns.message.from_wire(g[3]) == read and \
+ dns_message.read(wire)[2] == g[2]
+ paths[name] = right if len(got) == len(wires) else 0
+ outputs[name] = raw
+ same_paths = outputs['rounds'] == outputs['batches'] and outputs['all'] == outputs['batches']
+ raw, got, failed = run(module, [m for m, _ in broken], {}, work)
+ agreed = sum((want is None and f) or (want is not None and not f and g == want)
+ for (_, want), g, f in zip(broken, got, failed)) if len(got) == len(broken) else 0
+ refused = sum(want is None for _, want in broken)
+aup_code, aup_braces = lines(os.path.join(aup, 'examples', 'dns.aup'))
+python_code, _ = lines(os.path.join(here, 'dns_message.py'), stop='if __name__')
+ok = all(n == len(wires) for n in paths.values()) and same_paths and agreed == len(broken)
+print(json.dumps({'messages': len(wires), 'bytes': sum(map(len, wires)), 'records': sum(len(dns_message.read(w)[2]) for w in wires),
+ 'right': paths, 'paths_same': same_paths, 'damaged': len(broken), 'damaged_refused': refused,
+ 'damaged_agreed': agreed, 'aup_lines': aup_code, 'aup_brace_lines': aup_braces, 'python_lines': python_code}))
+sys.exit(0 if ok else 1)