# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
#
# SPDX-License-Identifier: MPL-2.0
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0.  If a copy of the MPL was not distributed with this
# 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.

from re import compile as Re

import os
import time

import dns.edns

import isctest

# Extended DNS Error INFO-CODE disclosing that a Negative Trust Anchor was
# applied to a response (draft-farrokhi-dnsop-ede-nta).
NTA_EDE_CODE = 33


def active(blob):
    return len([x for x in blob.splitlines() if " expiry" in x])


def has_ede(res, code):
    return any(
        opt.otype == dns.edns.OptionType.EDE and opt.code == code for opt in res.options
    )


# global start-time variable
# pylint: disable=global-statement
START = 0


def test_initial():
    m = isctest.query.create("a.bogus.example.", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)

    m = isctest.query.create("badds.example.", "SOA")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)

    m = isctest.query.create("a.secure.example.", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)


def test_nta_validate_except(servers):
    ns4 = servers["ns4"]
    response = ns4.rndc("secroots -")
    assert Re("^corp: permanent") in response.out

    # check insecure local domain works with validate-except
    m = isctest.query.create("www.corp", "NS")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)


def test_nta_bogus_lifetimes(servers):
    ns4 = servers["ns4"]

    # no nta lifetime specified:
    response = ns4.rndc("nta -l '' foo", raise_on_exception=False)
    assert "'nta' failed: bad ttl" in response.err

    # bad nta lifetime:
    response = ns4.rndc("nta -l garbage foo", raise_on_exception=False)
    assert "'nta' failed: bad ttl" in response.err

    # excessive nta lifetime:
    response = ns4.rndc("nta -l 7d1h foo", raise_on_exception=False)
    assert "'nta' failed: out of range" in response.err


def test_nta_install(servers):
    global START

    ns4 = servers["ns4"]
    ns4.rndc("nta -f -l 20s bogus.example")
    ns4.rndc("nta badds.example")

    # NTAs should persist after reconfig
    ns4.reconfigure()

    response = ns4.rndc("nta -d")
    assert len(response.out.splitlines()) == 3

    ns4.rndc("nta secure.example")
    ns4.rndc("nta fakenode.secure.example")
    with ns4.watch_log_from_here() as watcher:
        ns4.rndc("reload")
        watcher.wait_for_line("all zones loaded")

    response = ns4.rndc("nta -d")
    assert len(response.out.splitlines()) == 5

    START = time.time()


def test_nta_behavior(servers):
    assert START, "test_nta_behavior must be run as part of the full NTA test"

    m = isctest.query.create("a.bogus.example.", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    m = isctest.query.create("badds.example.", "SOA")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    m = isctest.query.create("a.secure.example.", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    m = isctest.query.create("a.fakenode.secure.example.", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noadflag(res)

    ns4 = servers["ns4"]
    response = ns4.rndc("secroots -")
    assert Re("^bogus.example: expiry") in response.out
    assert Re("^badds.example: expiry") in response.out
    assert Re("^secure.example: expiry") in response.out
    assert Re("^fakenode.secure.example: expiry") in response.out

    # secure.example and badds.example used the default nta-duration
    # (configured as 12s in ns4/named1.conf), but the nta recheck interval
    # is configured to 9s, so at t=10 the NTAs for secure.example and
    # fakenode.secure.example should both be lifted, while badds.example
    # should still be going.
    delay = START + 10 - time.time()
    if delay > 0:
        time.sleep(delay)

    m = isctest.query.create("b.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)

    m = isctest.query.create("b.fakenode.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.nxdomain(res)
    isctest.check.adflag(res)

    m = isctest.query.create("badds.example.", "SOA")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    # bogus.example was set to expire in 20s, so at t=13
    # it should still be NTA'd, but badds.example used the default
    # lifetime of 12s, so it should revert to SERVFAIL now.
    delay = START + 13 - time.time()
    if delay > 0:
        time.sleep(delay)

    response = ns4.rndc("nta -d")
    assert active(response.out) <= 2

    response = ns4.rndc("secroots -")
    assert Re("bogus.example: expiry") in response.out
    assert Re("badds.example: expiry") not in response.out

    m = isctest.query.create("b.bogus.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)

    m = isctest.query.create("a.badds.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)
    isctest.check.noadflag(res)

    m = isctest.query.create("c.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)

    # at t=21, all the NTAs should have expired.
    delay = START + 21 - time.time()
    if delay > 0:
        time.sleep(delay)

    response = ns4.rndc("nta -d")
    assert active(response.out) == 0

    m = isctest.query.create("d.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)

    m = isctest.query.create("c.bogus.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)
    isctest.check.noadflag(res)


def test_nta_removals(servers):
    ns4 = servers["ns4"]
    ns4.rndc("nta badds.example")

    response = ns4.rndc("nta -d")
    assert Re("^badds.example/_default: expiry") in response.out

    m = isctest.query.create("a.badds.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    response = ns4.rndc("nta -remove badds.example")
    assert "Negative trust anchor removed: badds.example" in response.out

    response = ns4.rndc("nta -d")
    assert Re("^badds.example/_default: expiry") not in response.out

    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)
    isctest.check.noadflag(res)

    # remove non-existent NTA three times
    ns4.rndc("nta -r foo")
    ns4.rndc("nta -remove foo")
    response = ns4.rndc("nta -r foo")
    assert "not found" in response.out


def test_nta_restarts(servers):
    global START
    assert START, "test_nta_restarts must be run as part of the full NTA test"

    # test NTA persistence across restarts
    ns4 = servers["ns4"]
    response = ns4.rndc("nta -d")
    assert active(response.out) == 0

    START = time.time()
    ns4.rndc("nta -f -l 30s bogus.example")
    ns4.rndc("nta -f -l 10s badds.example")
    response = ns4.rndc("nta -d")
    assert active(response.out) == 2

    # stop the server
    ns4.stop()

    # wait 14s before restarting. badds.example's NTA (lifetime=10s) should
    # have expired, and bogus.example should still be running.
    delay = START + 14 - time.time()
    if delay > 0:
        time.sleep(delay)
    ns4.start(["--noclean", "--restart", "--port", os.environ["PORT"]])

    response = ns4.rndc("nta -d")
    assert active(response.out) == 1
    assert Re("^bogus.example/_default: expiry") in response.out

    m = isctest.query.create("a.badds.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.servfail(res)

    m = isctest.query.create("a.bogus.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    ns4.rndc("nta -r bogus.example")


def test_nta_regular(servers):
    global START
    assert START, "test_nta_regular must be run as part of the full NTA test"

    # check "regular" attribute in NTA file
    ns4 = servers["ns4"]

    response = ns4.rndc("nta -d")
    assert active(response.out) == 0

    # secure.example validates with AD=1
    m = isctest.query.create("a.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)

    # stop the server, update _default.nta, restart
    ns4.stop()
    now = time.localtime()
    future = str(now.tm_year + 20) + "0101010000"
    with open("ns4/_default.nta", "w", encoding="utf-8") as f:
        f.write(f"secure.example. regular {future}")

    ns4.start(["--noclean", "--restart", "--port", os.environ["PORT"]])

    # NTA active; secure.example. should now return an AD=0 answer.
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    # nta-recheck is configured as 9s, so at t=12 the NTA for
    # secure.example. should be lifted as it is not a "forced" NTA.
    START = time.mktime(now)
    delay = START + 12 - time.time()
    if delay > 0:
        time.sleep(delay)

    response = ns4.rndc("nta -d")
    assert active(response.out) == 0

    # NTA lifted; secure.example. flush the cache to trigger a new query,
    # and it should now return an AD=1 answer.
    ns4.rndc("flushtree secure.example")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)


def test_nta_forced(servers):
    global START
    assert START, "test_nta_regular must be run as part of the full NTA test"

    # check "forced" attribute in NTA file
    ns4 = servers["ns4"]

    # just to be certain, clean up any existing NTA first
    ns4.rndc("nta -r secure.example")

    response = ns4.rndc("nta -d")
    assert active(response.out) == 0

    # secure.example validates with AD=1
    m = isctest.query.create("a.secure.example", "A")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.adflag(res)

    # stop the server, update _default.nta, restart
    ns4.stop()
    now = time.localtime()
    future = str(now.tm_year + 20) + "0101010000"
    with open("ns4/_default.nta", "w", encoding="utf-8") as f:
        f.write(f"secure.example. forced {future}")

    ns4.start(["--noclean", "--restart", "--port", os.environ["PORT"]])

    # NTA active; secure.example. should now return an AD=0 answer
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)

    # nta-recheck is configured as 9s. at t=12 the NTA for
    # secure.example. should NOT be lifted as it is "forced".
    START = time.mktime(now)
    delay = START + 12 - time.time()
    if delay > 0:
        time.sleep(delay)

    # NTA lifted; secure.example. should still return an AD=0 answer
    ns4.rndc("flushtree secure.example")
    res = isctest.query.tcp(m, "10.53.0.4")
    isctest.check.noerror(res)
    isctest.check.noadflag(res)


def test_nta_clamping(servers):
    ns4 = servers["ns4"]

    # clean up any existing NTA
    ns4.rndc("nta -r secure.example")

    # stop the server, update _default.nta, restart
    ns4.stop()
    now = time.localtime()
    future = str(now.tm_year + 20) + "0101010000"
    with open("ns4/_default.nta", "w", encoding="utf-8") as f:
        f.write(f"secure.example. forced {future}")

    ns4.start(["--noclean", "--restart", "--port", os.environ["PORT"]])

    # check that NTA lifetime read from file is clamped to 1 week.
    response = ns4.rndc("nta -d")
    assert active(response.out) == 1

    nta = next((s for s in response.out.splitlines() if " expiry" in s), None)
    assert nta is not None

    nta = nta.split(" ")
    expiry = f"{nta[2]} {nta[3]}"
    then = time.mktime(time.strptime(expiry, "%d-%b-%Y %H:%M:%S.000"))
    nextweek = time.mktime(now) + (86400 * 7)

    # normally there's no more than a few seconds difference between the
    # clamped expiration date and the calculated date for next week,
    # but add a 3600 second fudge factor to allow for daylight savings
    # changes.
    assert abs(nextweek - then < 3610)

    # remove the NTA
    ns4.rndc("nta -r secure.example")


def test_nta_forward(servers):
    ns9 = servers["ns9"]

    m = isctest.query.create("badds.example", "SOA")
    res = isctest.query.tcp(m, "10.53.0.9")
    isctest.check.servfail(res)
    isctest.check.empty_answer(res)
    isctest.check.noadflag(res)

    # add NTA and expect resolution to succeed
    ns9.rndc("nta badds.example")
    res = isctest.query.tcp(m, "10.53.0.9")
    isctest.check.noerror(res)
    isctest.check.rr_count_eq(res.answer, 2)
    isctest.check.noadflag(res)

    # remove NTA and expect resolution to fail again
    ns9.rndc("nta -remove badds.example")
    res = isctest.query.tcp(m, "10.53.0.9")
    isctest.check.servfail(res)
    isctest.check.empty_answer(res)
    isctest.check.noadflag(res)


def test_nta_ede(servers):
    # A response whose DNSSEC validation was suppressed by a Negative Trust
    # Anchor must disclose that via EDE code 33 (draft-farrokhi-dnsop-ede-nta).
    ns9 = servers["ns9"]

    m = isctest.query.create("badds.example", "SOA")

    # Without an NTA, validation fails: SERVFAIL and no NTA EDE.
    res = isctest.query.tcp(m, "10.53.0.9")
    isctest.check.servfail(res)
    assert not has_ede(res, NTA_EDE_CODE), res

    # With an NTA in place, the answer is returned (AD=0) and carries EDE 33.
    ns9.rndc("nta badds.example")
    try:
        res = isctest.query.tcp(m, "10.53.0.9")
        isctest.check.noerror(res)
        isctest.check.noadflag(res)
        isctest.check.ede(res, NTA_EDE_CODE)
    finally:
        ns9.rndc("nta -remove badds.example")

    # Once the NTA is gone, the disclosure stops too.
    res = isctest.query.tcp(m, "10.53.0.9")
    isctest.check.servfail(res)
    assert not has_ede(res, NTA_EDE_CODE), res
