tests-checkds.py 12.9 KB
Newer Older
Matthijs Mekking's avatar
Matthijs Mekking committed
1
#!/usr/bin/python3
2

Matthijs Mekking's avatar
Matthijs Mekking committed
3
4
# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
5
6
# SPDX-License-Identifier: MPL-2.0
#
Matthijs Mekking's avatar
Matthijs Mekking committed
7
# This Source Code Form is subject to the terms of the Mozilla Public
8
# License, v. 2.0.  If a copy of the MPL was not distributed with this
Matthijs Mekking's avatar
Matthijs Mekking committed
9
10
11
12
13
14
15
16
17
18
19
20
21
# file, you can obtain one at https://mozilla.org/MPL/2.0/.
#
# See the COPYRIGHT file distributed with this work for additional
# information regarding copyright ownership.

import mmap
import os
import subprocess
import sys
import time

import pytest

22
pytest.importorskip("dns", minversion="2.0.0")
23
24
25
26
27
28
29
30
import dns.exception
import dns.message
import dns.name
import dns.query
import dns.rcode
import dns.rdataclass
import dns.rdatatype
import dns.resolver
31

Matthijs Mekking's avatar
Matthijs Mekking committed
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

def has_signed_apex_nsec(zone, response):
    has_nsec = False
    has_rrsig = False

    ttl = 300
    nextname = "a."
    types = "NS SOA RRSIG NSEC DNSKEY CDS CDNSKEY"
    match = "{0} {1} IN NSEC {2}{0} {3}".format(zone, ttl, nextname, types)
    sig = "{0} {1} IN RRSIG NSEC 13 2 300".format(zone, ttl)

    for rr in response.answer:
        if match in rr.to_text():
            has_nsec = True
        if sig in rr.to_text():
            has_rrsig = True

    if not has_nsec:
        print("error: missing apex NSEC record in response")
    if not has_rrsig:
        print("error: missing NSEC signature in response")

    return has_nsec and has_rrsig


def do_query(server, qname, qtype, tcp=False):
58
    query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True)
Matthijs Mekking's avatar
Matthijs Mekking committed
59
60
    try:
        if tcp:
61
62
63
            response = dns.query.tcp(
                query, server.nameservers[0], timeout=3, port=server.port
            )
Matthijs Mekking's avatar
Matthijs Mekking committed
64
        else:
65
66
67
            response = dns.query.udp(
                query, server.nameservers[0], timeout=3, port=server.port
            )
Matthijs Mekking's avatar
Matthijs Mekking committed
68
    except dns.exception.Timeout:
69
70
71
72
73
        print(
            "error: query timeout for query {} {} to {}".format(
                qname, qtype, server.nameservers[0]
            )
        )
Matthijs Mekking's avatar
Matthijs Mekking committed
74
75
76
77
78
79
80
81
82
83
        return None

    return response


def verify_zone(zone, transfer):
    verify = os.getenv("VERIFY")
    assert verify is not None

    filename = "{}out".format(zone)
84
    with open(filename, "w", encoding="utf-8") as file:
Matthijs Mekking's avatar
Matthijs Mekking committed
85
86
        for rr in transfer.answer:
            file.write(rr.to_text())
87
            file.write("\n")
Matthijs Mekking's avatar
Matthijs Mekking committed
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

    # dnssec-verify command with default arguments.
    verify_cmd = [verify, "-z", "-o", zone, filename]

    verifier = subprocess.run(verify_cmd, capture_output=True, check=True)

    if verifier.returncode != 0:
        print("error: dnssec-verify {} failed".format(zone))
        sys.stderr.buffer.write(verifier.stderr)

    return verifier.returncode == 0


def read_statefile(server, zone):
    addr = server.nameservers[0]
    count = 0
    keyid = 0
    state = {}

    response = do_query(server, zone, "DS", tcp=True)
    if not isinstance(response, dns.message.Message):
        print("error: no response for {} DS from {}".format(zone, addr))
        return {}

    if response.rcode() == dns.rcode.NOERROR:
        # fetch key id from response.
        for rr in response.answer:
115
116
117
118
119
120
            if rr.match(
                dns.name.from_text(zone),
                dns.rdataclass.IN,
                dns.rdatatype.DS,
                dns.rdatatype.NONE,
            ):
Matthijs Mekking's avatar
Matthijs Mekking committed
121
122
123
124
125
                if count == 0:
                    keyid = list(dict(rr.items).items())[0][0].key_tag
                count += 1

        if count != 1:
126
127
128
129
            print(
                "error: expected a single DS in response for {} from {},"
                "got {}".format(zone, addr, count)
            )
Matthijs Mekking's avatar
Matthijs Mekking committed
130
131
            return {}
    else:
132
133
134
135
136
        print(
            "error: {} response for {} DNSKEY from {}".format(
                dns.rcode.to_text(response.rcode()), zone, addr
            )
        )
Matthijs Mekking's avatar
Matthijs Mekking committed
137
138
139
140
141
142
        return {}

    filename = "ns9/K{}+013+{:05d}.state".format(zone, keyid)
    print("read state file {}".format(filename))

    try:
143
        with open(filename, "r", encoding="utf-8") as file:
Matthijs Mekking's avatar
Matthijs Mekking committed
144
            for line in file:
145
                if line.startswith(";"):
Matthijs Mekking's avatar
Matthijs Mekking committed
146
                    continue
147
                key, val = line.strip().split(":", 1)
Matthijs Mekking's avatar
Matthijs Mekking committed
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
                state[key.strip()] = val.strip()

    except FileNotFoundError:
        # file may not be written just yet.
        return {}

    return state


def zone_check(server, zone):
    addr = server.nameservers[0]

    # wait until zone is fully signed.
    signed = False
    for _ in range(10):
163
        response = do_query(server, zone, "NSEC")
Matthijs Mekking's avatar
Matthijs Mekking committed
164
165
166
167
168
        if not isinstance(response, dns.message.Message):
            print("error: no response for {} NSEC from {}".format(zone, addr))
        elif response.rcode() == dns.rcode.NOERROR:
            signed = has_signed_apex_nsec(zone, response)
        else:
169
170
171
172
173
            print(
                "error: {} response for {} NSEC from {}".format(
                    dns.rcode.to_text(response.rcode()), zone, addr
                )
            )
Matthijs Mekking's avatar
Matthijs Mekking committed
174
175
176
177
178
179
180
181
182
183

        if signed:
            break

        time.sleep(1)

    assert signed

    # check if zone if DNSSEC valid.
    verified = False
184
    transfer = do_query(server, zone, "AXFR", tcp=True)
Matthijs Mekking's avatar
Matthijs Mekking committed
185
186
187
188
189
    if not isinstance(transfer, dns.message.Message):
        print("error: no response for {} AXFR from {}".format(zone, addr))
    elif transfer.rcode() == dns.rcode.NOERROR:
        verified = verify_zone(zone, transfer)
    else:
190
191
192
193
194
        print(
            "error: {} response for {} AXFR from {}".format(
                dns.rcode.to_text(transfer.rcode()), zone, addr
            )
        )
Matthijs Mekking's avatar
Matthijs Mekking committed
195
196
197
198
199
200
201
202
203

    assert verified


def keystate_check(server, zone, key):
    val = 0
    deny = False

    search = key
204
    if key.startswith("!"):
Matthijs Mekking's avatar
Matthijs Mekking committed
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
        deny = True
        search = key[1:]

    for _ in range(10):
        state = read_statefile(server, zone)
        try:
            val = state[search]
        except KeyError:
            pass

        if not deny and val != 0:
            break
        if deny and val == 0:
            break

        time.sleep(1)

    if deny:
        assert val == 0
    else:
        assert val != 0


def wait_for_log(filename, log):
    found = False

    for _ in range(10):
        print("read log file {}".format(filename))

        try:
235
            with open(filename, "r", encoding="utf-8") as file:
Matthijs Mekking's avatar
Matthijs Mekking committed
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
                s = mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ)
                if s.find(bytes(log, "ascii")) != -1:
                    found = True
        except FileNotFoundError:
            print("file not found {}".format(filename))

        if found:
            break

        print("sleep")
        time.sleep(1)

    assert found


def test_checkds_dspublished(named_port):
    # We create resolver instances that will be used to send queries.
    server = dns.resolver.Resolver()
    server.nameservers = ["10.53.0.9"]
    server.port = named_port

    parent = dns.resolver.Resolver()
    parent.nameservers = ["10.53.0.2"]
    parent.port = named_port

    # DS correctly published in parent.
    zone_check(server, "dspublished.checkds.")
263
264
    wait_for_log(
        "ns9/named.run",
265
        "zone dspublished.checkds/IN (signed): checkds: DS response from 10.53.0.2",
266
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
267
268
269
270
    keystate_check(parent, "dspublished.checkds.", "DSPublish")

    # DS correctly published in parent (reference to parental-agent).
    zone_check(server, "reference.checkds.")
271
272
    wait_for_log(
        "ns9/named.run",
273
        "zone reference.checkds/IN (signed): checkds: DS response from 10.53.0.2",
274
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
275
276
277
278
    keystate_check(parent, "reference.checkds.", "DSPublish")

    # DS not published in parent.
    zone_check(server, "missing-dspublished.checkds.")
279
280
281
282
283
    wait_for_log(
        "ns9/named.run",
        "zone missing-dspublished.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
284
285
286
287
    keystate_check(parent, "missing-dspublished.checkds.", "!DSPublish")

    # Badly configured parent.
    zone_check(server, "bad-dspublished.checkds.")
288
289
290
291
292
    wait_for_log(
        "ns9/named.run",
        "zone bad-dspublished.checkds/IN (signed): checkds: "
        "bad DS response from 10.53.0.6",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
293
294
295
296
297
298
    keystate_check(parent, "bad-dspublished.checkds.", "!DSPublish")

    # TBD: DS published in parent, but bogus signature.

    # DS correctly published in all parents.
    zone_check(server, "multiple-dspublished.checkds.")
299
300
301
302
303
304
305
306
307
308
    wait_for_log(
        "ns9/named.run",
        "zone multiple-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.2",
    )
    wait_for_log(
        "ns9/named.run",
        "zone multiple-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.4",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
309
310
311
312
    keystate_check(parent, "multiple-dspublished.checkds.", "DSPublish")

    # DS published in only one of multiple parents.
    zone_check(server, "incomplete-dspublished.checkds.")
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.2",
    )
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.4",
    )
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dspublished.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
328
329
330
    keystate_check(parent, "incomplete-dspublished.checkds.", "!DSPublish")

    # One of the parents is badly configured.
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.2",
    )
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dspublished.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.4",
    )
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dspublished.checkds/IN (signed): checkds: "
        "bad DS response from 10.53.0.6",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
    keystate_check(parent, "bad2-dspublished.checkds.", "!DSPublish")

    # TBD: DS published in all parents, but one has bogus signature.

    # TBD: Check with TSIG

    # TBD: Check with TLS


def test_checkds_dswithdrawn(named_port):
    # We create resolver instances that will be used to send queries.
    server = dns.resolver.Resolver()
    server.nameservers = ["10.53.0.9"]
    server.port = named_port

    parent = dns.resolver.Resolver()
    parent.nameservers = ["10.53.0.2"]
    parent.port = named_port

    # DS correctly published in single parent.
    zone_check(server, "dswithdrawn.checkds.")
367
368
369
370
371
    wait_for_log(
        "ns9/named.run",
        "zone dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
372
373
374
375
    keystate_check(parent, "dswithdrawn.checkds.", "DSRemoved")

    # DS not withdrawn from parent.
    zone_check(server, "missing-dswithdrawn.checkds.")
376
377
378
379
380
    wait_for_log(
        "ns9/named.run",
        "zone missing-dswithdrawn.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.2",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
381
382
383
384
    keystate_check(parent, "missing-dswithdrawn.checkds.", "!DSRemoved")

    # Badly configured parent.
    zone_check(server, "bad-dswithdrawn.checkds.")
385
386
387
388
389
    wait_for_log(
        "ns9/named.run",
        "zone bad-dswithdrawn.checkds/IN (signed): checkds: "
        "bad DS response from 10.53.0.6",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
390
391
392
393
394
395
    keystate_check(parent, "bad-dswithdrawn.checkds.", "!DSRemoved")

    # TBD: DS published in parent, but bogus signature.

    # DS correctly withdrawn from all parents.
    zone_check(server, "multiple-dswithdrawn.checkds.")
396
397
398
399
400
401
402
403
404
405
    wait_for_log(
        "ns9/named.run",
        "zone multiple-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
    wait_for_log(
        "ns9/named.run",
        "zone multiple-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.7",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
406
407
408
409
    keystate_check(parent, "multiple-dswithdrawn.checkds.", "DSRemoved")

    # DS withdrawn from only one of multiple parents.
    zone_check(server, "incomplete-dswithdrawn.checkds.")
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
        "DS response from 10.53.0.2",
    )
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
    wait_for_log(
        "ns9/named.run",
        "zone incomplete-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.7",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
425
426
427
    keystate_check(parent, "incomplete-dswithdrawn.checkds.", "!DSRemoved")

    # One of the parents is badly configured.
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.5",
    )
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
        "empty DS response from 10.53.0.7",
    )
    wait_for_log(
        "ns9/named.run",
        "zone bad2-dswithdrawn.checkds/IN (signed): checkds: "
        "bad DS response from 10.53.0.6",
    )
Matthijs Mekking's avatar
Matthijs Mekking committed
443
444
445
    keystate_check(parent, "bad2-dswithdrawn.checkds.", "!DSRemoved")

    # TBD: DS withdrawn from all parents, but one has bogus signature.