diff options
Diffstat (limited to 'research/experiments/044-specs/dns_check.py')
| -rw-r--r-- | research/experiments/044-specs/dns_check.py | 166 |
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) |
