aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/044-specs/dns_check.py
blob: d492fda5a9938cb4bb23e1be1ebdc7d9be2ac09a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
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)