From b9eeb545c3a936d005d1d12584bed80a56fae202 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Sun, 26 Jul 2026 20:38:42 +0200 Subject: [PATCH] Test complete Technitium DNSSEC stack --- contrib/testing/README.md | 32 +- contrib/testing/smtp-dane-mta-sts-check.py | 425 +++++++++++++++++---- docs/release-testing-0.7.md | 2 +- docs/test-evidence/0.7-r1.md | 44 ++- 4 files changed, 390 insertions(+), 113 deletions(-) diff --git a/contrib/testing/README.md b/contrib/testing/README.md index d9d9a21..f3cd921 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -70,22 +70,22 @@ one-off files in `/tmp`: configuration, and service restoration guarantees as the opportunistic check. - `smtp-dane-mta-sts-check.py` creates a disposable ECDSA-signed DNS zone, - authoritative BIND instance, HTTPS policy host, and eight loopback MX - fixtures. `--authority bind` uses BIND to sign and serve the zone, while - `--authority technitium` imports the same unsigned records into an isolated - authoritative Technitium DNS Server and signs them through its HTTP API. - Both authoritative servers feed the same validating Unbound stub, and the - fixture temporarily trusts the selected zone KSK in Bongo. The two runs - prove that MX, TLSA, and RRSIG data reach Bongo through both DNS - implementations with an identical validated resolver boundary. The - delivery matrix covers DANE-EE with a certificate - outside the Web PKI, TLSA mismatch deferral, DANE precedence, MTA-STS - enforce/testing behavior, exact and wildcard MX policy matching, and - durable cached-policy use while HTTPS is unavailable. The root-only fixture - backs up and atomically restores `resolv.conf`, the DNS trust-anchor file, - SMTP Store configuration, Queue entries, generated certificates, and the - original service state. Technitium uses only a temporary configuration - directory; its system service and `/etc/dns` are not used. + HTTPS policy host, and eight loopback MX fixtures. Run `--live --dns-stack + bind-unbound` for the BIND authority plus validating Unbound reference + stack, or `--live --dns-stack technitium` for independent authoritative and + recursive Technitium DNS Server instances. The all-Technitium path creates + a signed local root, delegates the Bongo fixture zone with DS, and gives the + recursive instance isolated root hints and that root trust anchor. It must + return a DNSSEC-authenticated MX response with AD and RRSIG before Bongo is + started. Both complete stacks run the identical delivery matrix covering + DANE-EE with a certificate outside the Web PKI, TLSA mismatch deferral, + DANE precedence, MTA-STS enforce/testing behavior, exact and wildcard MX + policy matching, and durable cached-policy use while HTTPS is unavailable. + The root-only fixture backs up and atomically restores `resolv.conf`, the + Bongo DNS trust-anchor file, SMTP Store configuration, Queue entries, + generated certificates, and the original service state. Technitium uses + only temporary runtime and configuration directories; its system service + and `/etc/dns` are not used. - `sendmail-local-submission-check.py` runs as an unprivileged Unix account and verifies both `/usr/sbin/sendmail` and GNU `mail` through Bongo's restricted, rate-limited local SMTP submission path. It confirms that no diff --git a/contrib/testing/smtp-dane-mta-sts-check.py b/contrib/testing/smtp-dane-mta-sts-check.py index 835f77a..2b42cfb 100755 --- a/contrib/testing/smtp-dane-mta-sts-check.py +++ b/contrib/testing/smtp-dane-mta-sts-check.py @@ -40,12 +40,11 @@ TOKEN = f"smtp27-{os.getpid()}-{int(time.time())}" # embedded resolver exercises the same validating path as an Internet domain. ZONE = "smtp27.bongo-test" DNS_RESOLVER = "127.0.0.53" -DNS_AUTHORITY = "127.0.0.1" -# Avoid mDNS' wildcard UDP/5353 listener, which may be active on desktops. -DNS_AUTHORITY_PORT = 15353 +DNS_AUTHORITY_PORT = 53 TECHNITIUM_HTTP_ADDRESS = "127.0.0.1" -TECHNITIUM_HTTP_PORT = 15380 TECHNITIUM_PASSWORD = f"{TOKEN}-technitium" +TECHNITIUM_AUTHORITY_HTTP_PORT = 15380 +TECHNITIUM_RESOLVER_HTTP_PORT = 15381 HTTPS_ADDRESS = "127.0.0.9" TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "75")) STARTUP_SETTLE = float(os.environ.get("BONGO_TEST_STARTUP_SETTLE", "3")) @@ -60,6 +59,44 @@ class SMTP27Error(RuntimeError): """Raised when a DANE or MTA-STS invariant is violated.""" +@dataclass(frozen=True) +class TechnitiumEndpoint: + """Network endpoints for one disposable Technitium instance.""" + + name: str + dns_address: str + dns_port: int + http_port: int + + @property + def password(self) -> str: + return f"{TECHNITIUM_PASSWORD}-{self.name}" + + +TECHNITIUM_RESOLVER = TechnitiumEndpoint( + "resolver", + DNS_RESOLVER, + 53, + TECHNITIUM_RESOLVER_HTTP_PORT, +) + + +def select_authority_address() -> str: + """Return the IPv4 source address used for non-loopback local traffic.""" + + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + # UDP connect performs only a routing lookup and sends no packet. + probe.connect(("192.0.2.1", 9)) + address = str(probe.getsockname()[0]) + finally: + probe.close() + if address.startswith("127."): + raise SMTP27Error( + "Technitium recursion requires a non-loopback authority address") + return address + + def load_smtp07_helpers(): path = Path(__file__).with_name( "smtp-outbound-opportunistic-tls-check.py") @@ -329,14 +366,18 @@ def issue_certificates() -> tuple[Path, Path, list[Path]]: return trusted, untrusted, [trusted, untrusted] -def write_zone(directory: Path, valid_tlsa: str) -> None: +def write_zone( + directory: Path, + valid_tlsa: str, + authority_address: str, +) -> None: lines = [ f"$ORIGIN {ZONE}.", "$TTL 30", (f"@ IN SOA ns.{ZONE}. hostmaster.{ZONE}. " f"( {int(time.time())} 30 30 300 30 )"), f" IN NS ns.{ZONE}.", - f"ns IN A {DNS_AUTHORITY}", + f"ns IN A {authority_address}", ] for route in ROUTES: lines.extend([ @@ -357,6 +398,30 @@ def write_zone(directory: Path, valid_tlsa: str) -> None: encoding="ascii") +def write_root_zone( + directory: Path, + child_ds: str, + authority_address: str, +) -> Path: + fields = child_ds.split() + if len(fields) < 7 or fields[-5].upper() != "DS": + raise SMTP27Error(f"invalid child DS record: {child_ds}") + root_zone = directory / "root.zone" + root_zone.write_text( + "$ORIGIN .\n" + "$TTL 30\n" + f"@ IN SOA root.smtp27-root. hostmaster.{ZONE}. " + f"( {int(time.time())} 30 30 300 30 )\n" + "@ IN NS root.smtp27-root.\n" + f"root.smtp27-root. IN A {authority_address}\n" + f"{ZONE}. IN NS ns.{ZONE}.\n" + f"ns.{ZONE}. IN A {authority_address}\n" + f"{ZONE}. IN DS {' '.join(fields[-4:])}\n", + encoding="ascii", + ) + return root_zone + + def sign_zone_with_bind(directory: Path) -> str: run([ "dnssec-keygen", "-q", "-K", ".", "-a", "ECDSAP256SHA256", @@ -377,11 +442,16 @@ def sign_zone_with_bind(directory: Path) -> str: raise SMTP27Error("dnssec-keygen did not produce a KSK trust anchor") -def write_dns_configs(directory: Path, trust_anchor: str) -> None: +def write_bind_unbound_configs( + directory: Path, + trust_anchor: str, + authority_address: str, +) -> None: (directory / "named.conf").write_text( "options {\n" f' directory "{directory}";\n' - f" listen-on port {DNS_AUTHORITY_PORT} {{ {DNS_AUTHORITY}; }};\n" + f" listen-on port {DNS_AUTHORITY_PORT} " + f"{{ {authority_address}; }};\n" " listen-on-v6 { none; };\n" " recursion no;\n" " dnssec-validation no;\n" @@ -410,7 +480,7 @@ def write_dns_configs(directory: Path, trust_anchor: str) -> None: f' trust-anchor-file: "{directory / "anchor"}"\n' "stub-zone:\n" f' name: "{ZONE}."\n' - f" stub-addr: {DNS_AUTHORITY}@{DNS_AUTHORITY_PORT}\n", + f" stub-addr: {authority_address}@{DNS_AUTHORITY_PORT}\n", encoding="ascii", ) @@ -450,13 +520,14 @@ def stop_process( def technitium_api( + endpoint: TechnitiumEndpoint, path: str, parameters: dict[str, str] | None = None, *, token: str | None = None, ) -> dict: url = ( - f"http://{TECHNITIUM_HTTP_ADDRESS}:{TECHNITIUM_HTTP_PORT}" + f"http://{TECHNITIUM_HTTP_ADDRESS}:{endpoint.http_port}" f"{path}" ) headers = {} @@ -473,23 +544,29 @@ def technitium_api( payload = json.load(response) except (urllib.error.URLError, TimeoutError, ValueError) as error: raise SMTP27Error( - f"Technitium API request {path} failed: {error}") from error + f"Technitium {endpoint.name} API request {path} failed: " + f"{error}") from error if payload.get("status") != "ok": raise SMTP27Error( - f"Technitium API request {path} failed: " + f"Technitium {endpoint.name} API request {path} failed: " f"{payload.get('errorMessage', payload.get('status', payload))}") return payload -def technitium_import_zone(zone_file: Path, token: str) -> None: +def technitium_import_zone( + endpoint: TechnitiumEndpoint, + zone_name: str, + zone_file: Path, + token: str, +) -> None: query = urllib.parse.urlencode({ - "zone": ZONE, + "zone": zone_name, "overwrite": "true", "overwriteZone": "true", "overwriteSoaSerial": "true", }) url = ( - f"http://{TECHNITIUM_HTTP_ADDRESS}:{TECHNITIUM_HTTP_PORT}" + f"http://{TECHNITIUM_HTTP_ADDRESS}:{endpoint.http_port}" f"/api/zones/import?{query}" ) request = urllib.request.Request( @@ -506,118 +583,292 @@ def technitium_import_zone(zone_file: Path, token: str) -> None: payload = json.load(response) except (urllib.error.URLError, TimeoutError, ValueError) as error: raise SMTP27Error( - f"Technitium zone import failed: {error}") from error + f"Technitium {endpoint.name} zone import failed: " + f"{error}") from error if payload.get("status") != "ok": raise SMTP27Error( - "Technitium zone import failed: " + f"Technitium {endpoint.name} zone import failed: " f"{payload.get('errorMessage', payload.get('status', payload))}") -def technitium_trust_anchor() -> str: +def dnskey_answer( + endpoint: TechnitiumEndpoint, + zone_name: str, +) -> str: deadline = time.monotonic() + 15 last_output = "" while time.monotonic() < deadline: result = run([ - "dig", f"@{DNS_AUTHORITY}", "-p", str(DNS_AUTHORITY_PORT), + "dig", f"@{endpoint.dns_address}", + "-p", str(endpoint.dns_port), "+time=1", "+tries=1", - "+noall", "+answer", "DNSKEY", ZONE, + "+noall", "+answer", "DNSKEY", zone_name, ], check=False) last_output = result.stdout.strip() - for line in result.stdout.splitlines(): - fields = line.split() - if (len(fields) >= 8 and fields[3].upper() == "DNSKEY" - and fields[4] == "257"): - return " ".join(fields) + if any( + len(line.split()) >= 8 + and line.split()[3].upper() == "DNSKEY" + and line.split()[4] == "257" + for line in result.stdout.splitlines() + ): + return result.stdout time.sleep(0.2) raise SMTP27Error( - "Technitium did not publish a KSK trust anchor" + f"Technitium {endpoint.name} did not publish a KSK for {zone_name}" + (f": {last_output}" if last_output else "")) -def wait_for_technitium_api(process: subprocess.Popen) -> None: +def dnskey_trust_anchor( + endpoint: TechnitiumEndpoint, + zone_name: str, +) -> str: + for line in dnskey_answer(endpoint, zone_name).splitlines(): + fields = line.split() + if (len(fields) >= 8 and fields[3].upper() == "DNSKEY" + and fields[4] == "257"): + return " ".join(fields) + raise SMTP27Error( + f"Technitium {endpoint.name} returned no KSK for {zone_name}") + + +def dnssec_ds_record( + directory: Path, + endpoint: TechnitiumEndpoint, + zone_name: str, +) -> str: + safe_name = "root" if zone_name == "." else zone_name.replace(".", "-") + key_file = directory / f"{safe_name}.dnskey" + key_file.write_text( + dnskey_answer(endpoint, zone_name), + encoding="ascii", + ) + completed = run([ + "dnssec-dsfromkey", "-2", "-f", key_file, zone_name, + ]) + for line in completed.stdout.splitlines(): + fields = line.split() + if len(fields) >= 7 and fields[-5].upper() == "DS": + return " ".join(fields) + raise SMTP27Error( + f"dnssec-dsfromkey returned no DS for {zone_name}: " + f"{completed.stdout.strip()}") + + +def wait_for_technitium_api( + endpoint: TechnitiumEndpoint, + process: subprocess.Popen, +) -> None: deadline = time.monotonic() + 30 last_error: BaseException | None = None while time.monotonic() < deadline: if process.poll() is not None: raise SMTP27Error( - f"Technitium exited during startup with status " + f"Technitium {endpoint.name} exited during startup with status " f"{process.returncode}") try: - technitium_api("/api/status") + technitium_api(endpoint, "/api/status") return except SMTP27Error as error: last_error = error time.sleep(0.25) raise SMTP27Error( - f"Technitium web API did not become ready: {last_error}") + f"Technitium {endpoint.name} web API did not become ready: " + f"{last_error}") -def start_technitium( +def start_technitium_instance( directory: Path, + endpoint: TechnitiumEndpoint, + *, + dnssec_validation: bool, + recursion: str, + runtime_directory: Path | None = None, ) -> tuple[subprocess.Popen, str]: executable = shutil.which("technitium-dns") if executable is None: raise SMTP27Error( "technitium-dns is unavailable; install " "net-dns/technitium-dns") - config_directory = directory / "technitium" + config_directory = directory / f"technitium-{endpoint.name}" config_directory.mkdir(mode=0o700) environment = os.environ.copy() environment.update({ - "DNS_SERVER_DOMAIN": f"resolver.{ZONE}", - "DNS_SERVER_ADMIN_PASSWORD": TECHNITIUM_PASSWORD, + "DNS_SERVER_DOMAIN": f"{endpoint.name}.{ZONE}", + "DNS_SERVER_ADMIN_PASSWORD": endpoint.password, "DNS_SERVER_WEB_SERVICE_LOCAL_ADDRESSES": TECHNITIUM_HTTP_ADDRESS, - "DNS_SERVER_WEB_SERVICE_HTTP_PORT": str(TECHNITIUM_HTTP_PORT), - "DNS_SERVER_RECURSION": "Allow", + "DNS_SERVER_WEB_SERVICE_HTTP_PORT": str(endpoint.http_port), + "DNS_SERVER_RECURSION": recursion, "DNS_SERVER_ENABLE_BLOCKING": "false", "DNS_SERVER_LOG_FOLDER_PATH": str(config_directory / "logs"), "DNS_SERVER_STATS_ENABLE_IN_MEMORY_STATS": "true", }) + command = [executable, str(config_directory)] + if runtime_directory is not None: + dotnet = shutil.which("dotnet") + if dotnet is None: + raise SMTP27Error( + "dotnet is unavailable for the isolated Technitium runtime") + command = [ + dotnet, + str(runtime_directory / "DnsServerApp.dll"), + str(config_directory), + ] process = start_process( - [executable, str(config_directory)], - directory / "technitium-process.log", + command, + directory / f"technitium-{endpoint.name}.log", environment=environment, ) try: - wait_for_technitium_api(process) - login = technitium_api("/api/user/login", { + wait_for_technitium_api(endpoint, process) + login = technitium_api(endpoint, "/api/user/login", { "user": "admin", - "pass": TECHNITIUM_PASSWORD, + "pass": endpoint.password, "includeInfo": "true", }) token = str(login.get("token", "")) if not token: - raise SMTP27Error("Technitium login returned no session token") - technitium_api("/api/settings/set", { + raise SMTP27Error( + f"Technitium {endpoint.name} login returned no session token") + technitium_api(endpoint, "/api/settings/set", { "dnsServerLocalEndPoints": - f"{DNS_AUTHORITY}:{DNS_AUTHORITY_PORT}", - "dnssecValidation": "false", - "recursion": "Allow", + f"{endpoint.dns_address}:{endpoint.dns_port}", + "dnssecValidation": + "true" if dnssec_validation else "false", + "recursion": recursion, "enableBlocking": "false", "saveCache": "false", "loggingType": "Console", "enableInMemoryStats": "true", }, token=token) - technitium_api("/api/zones/create", { + except BaseException: + stop_process(process, signal.SIGINT) + raise + return process, token + + +def start_technitium_authority( + directory: Path, + endpoint: TechnitiumEndpoint, +) -> tuple[subprocess.Popen, str, str]: + process, token = start_technitium_instance( + directory, + endpoint, + dnssec_validation=False, + recursion="Deny", + ) + try: + technitium_api(endpoint, "/api/zones/create", { "zone": ZONE, "type": "Primary", "useSoaSerialDateScheme": "false", }, token=token) - technitium_import_zone(directory / "zone", token) - technitium_api("/api/zones/dnssec/sign", { - "zone": ZONE, - "algorithm": "ECDSA", - "curve": "P256", - "dnsKeyTtl": "30", - "zskRolloverDays": "0", - "nxProof": "NSEC", + technitium_import_zone( + endpoint, ZONE, directory / "zone", token) + technitium_api( + endpoint, + "/api/zones/dnssec/sign", + { + "zone": ZONE, + "algorithm": "ECDSA", + "curve": "P256", + "dnsKeyTtl": "30", + "zskRolloverDays": "0", + "nxProof": "NSEC", + }, + token=token, + ) + child_anchor = dnskey_trust_anchor(endpoint, ZONE) + child_ds = dnssec_ds_record( + directory, endpoint, ZONE) + root_zone = write_root_zone( + directory, child_ds, endpoint.dns_address) + technitium_api(endpoint, "/api/zones/create", { + "zone": ".", + "type": "Primary", + "useSoaSerialDateScheme": "false", }, token=token) - trust_anchor = technitium_trust_anchor() + technitium_import_zone( + endpoint, ".", root_zone, token) + technitium_api( + endpoint, + "/api/zones/dnssec/sign", + { + "zone": ".", + "algorithm": "ECDSA", + "curve": "P256", + "dnsKeyTtl": "30", + "zskRolloverDays": "0", + "nxProof": "NSEC", + }, + token=token, + ) + root_ds = dnssec_ds_record( + directory, endpoint, ".") except BaseException: stop_process(process, signal.SIGINT) raise - return process, trust_anchor + return process, child_anchor, root_ds + + +def technitium_runtime_source() -> Path: + candidates = [ + path for path in Path("/usr/share").glob("technitium-dns-*") + if (path / "DnsServerApp.dll").is_file() + ] + if not candidates: + raise SMTP27Error("installed Technitium runtime directory not found") + return max(candidates, key=lambda path: path.name) + + +def prepare_technitium_resolver_runtime( + directory: Path, + root_ds: str, + authority_address: str, +) -> Path: + fields = root_ds.split() + if len(fields) < 7 or fields[-5].upper() != "DS": + raise SMTP27Error(f"invalid root DS record: {root_ds}") + runtime = directory / "technitium-resolver-runtime" + shutil.copytree(technitium_runtime_source(), runtime, symlinks=True) + (runtime / "named.root").write_text( + ". 30 IN NS root.smtp27-root.\n" + f"root.smtp27-root. 30 IN A {authority_address}\n", + encoding="ascii", + ) + key_tag, algorithm, digest_type, digest = fields[-4:] + (runtime / "root-anchors.xml").write_text( + '\n' + '\n' + ' .\n' + ' \n' + f" {key_tag}\n" + f" {algorithm}\n" + f" {digest_type}\n" + f" {digest}\n" + " \n" + "\n", + encoding="ascii", + ) + return runtime + + +def start_technitium_resolver( + directory: Path, + root_ds: str, + authority_address: str, +) -> subprocess.Popen: + runtime = prepare_technitium_resolver_runtime( + directory, root_ds, authority_address) + process, _token = start_technitium_instance( + directory, + TECHNITIUM_RESOLVER, + dnssec_validation=True, + recursion="Allow", + runtime_directory=runtime, + ) + return process def wait_for_dns() -> None: @@ -855,7 +1106,7 @@ def restore_file(path: Path, backup: Path) -> None: backup.unlink() -def perform_test(authority_backend: str) -> None: +def perform_test(dns_stack: str) -> None: if os.geteuid() != 0: raise SMTP27Error("the live DNSSEC fixture must run as root") if RESOLV_BACKUP.exists() or ANCHOR_BACKUP.exists(): @@ -903,6 +1154,13 @@ def perform_test(authority_backend: str) -> None: trusted_context = certificate_context(trusted) untrusted_context = certificate_context(untrusted) valid_tlsa = certificate_sha256(untrusted / "cert.pem") + authority_address = select_authority_address() + authority_endpoint = TechnitiumEndpoint( + "authority", + authority_address, + DNS_AUTHORITY_PORT, + TECHNITIUM_AUTHORITY_HTTP_PORT, + ) bongo_user = pwd.getpwnam("bongo") # named drops privileges to its service account. Keep this isolated @@ -913,11 +1171,12 @@ def perform_test(authority_backend: str) -> None: cache_directory = dns_directory / "mta-sts-cache" cache_directory.mkdir(mode=0o750) os.chown(cache_directory, bongo_user.pw_uid, bongo_user.pw_gid) - write_zone(dns_directory, valid_tlsa) + write_zone(dns_directory, valid_tlsa, authority_address) trust_anchor = "" - if authority_backend == "bind": + if dns_stack == "bind-unbound": trust_anchor = sign_zone_with_bind(dns_directory) - write_dns_configs(dns_directory, trust_anchor) + write_bind_unbound_configs( + dns_directory, trust_anchor, authority_address) test_smtp = copy.deepcopy(original_smtp) test_smtp["port"] = SMTP07.TEMPORARY_PUBLIC_PORT @@ -941,20 +1200,24 @@ def perform_test(authority_backend: str) -> None: resolver_metadata, ) resolver_changed = True - if authority_backend == "bind": + if dns_stack == "bind-unbound": authority_process = start_process( ["named", "-g", "-c", str(dns_directory / "named.conf")], dns_directory / "named.log", ) + resolver_process = start_process( + ["unbound", "-d", "-c", + str(dns_directory / "unbound.conf")], + dns_directory / "unbound-process.log", + ) else: - authority_process, trust_anchor = start_technitium(dns_directory) - write_dns_configs(dns_directory, trust_anchor) - resolver_process = start_process( - ["unbound", "-d", "-c", - str(dns_directory / "unbound.conf")], - dns_directory / "unbound-process.log", - ) + authority_process, trust_anchor, root_ds = ( + start_technitium_authority( + dns_directory, authority_endpoint) + ) + resolver_process = start_technitium_resolver( + dns_directory, root_ds, authority_address) try: wait_for_dns() except SMTP27Error as error: @@ -963,7 +1226,8 @@ def perform_test(authority_backend: str) -> None: "named.log", "unbound-process.log", "unbound.log", - "technitium-process.log", + "technitium-authority.log", + "technitium-resolver.log", ): path = dns_directory / name if path.exists(): @@ -1008,11 +1272,12 @@ def perform_test(authority_backend: str) -> None: stop_server(server) stop_process( resolver_process, - signal.SIGTERM, + signal.SIGINT if dns_stack == "technitium" + else signal.SIGTERM, ) stop_process( authority_process, - signal.SIGINT if authority_backend == "technitium" + signal.SIGINT if dns_stack == "technitium" else signal.SIGTERM, ) @@ -1068,11 +1333,14 @@ def perform_test(authority_backend: str) -> None: if primary_error is not None: raise primary_error + authority_backend = "technitium" if dns_stack == "technitium" else "bind" + resolver_backend = ( + "technitium" if dns_stack == "technitium" else "unbound") print( "SMTP-27 PASS " "dane=untrusted-ee/mismatch-deferred/precedence " "mta-sts=enforce/testing/mx-mismatch/cache " - "dnssec=validated resolver=unbound " + f"dnssec=validated resolver={resolver_backend} " f"authority={authority_backend} queue-clean=yes " f"service-restored={'active' if original_active else 'inactive'}" ) @@ -1092,17 +1360,18 @@ def main() -> int: action.add_argument("--live", action="store_true") action.add_argument("--restore", action="store_true") parser.add_argument( - "--authority", - choices=("bind", "technitium"), - default="bind", - help="authoritative DNS fixture backend (default: bind)", + "--dns-stack", + choices=("bind-unbound", "technitium"), + default="bind-unbound", + help="complete authoritative/validating DNS stack " + "(default: bind-unbound)", ) arguments = parser.parse_args() try: if arguments.restore: restore_only() else: - perform_test(arguments.authority) + perform_test(arguments.dns_stack) except (OSError, RuntimeError, smtplib.SMTPException) as error: print(f"smtp-dane-mta-sts-check: {error}", file=sys.stderr) return 1 diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index fd8b298..8711ab1 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -135,7 +135,7 @@ inputs and outputs is absent from the ZIP. | SMTP-33 | Load threshold gives newly accepted inbound, trusted-device, and submission sessions the short stress timeout | [PASS](test-evidence/0.7-r1.md#smtp-33) | | | | SMTP-07 | Internet delivery prefers STARTTLS and follows opportunistic fallback | [PASS](test-evidence/0.7-r1.md#smtp-07) | | | | SMTP-08 | Required TLS and `REQUIRETLS` defer rather than downgrade | [PASS](test-evidence/0.7-r1.md#smtp-08) | | | -| SMTP-27 | DANE takes precedence; MTA-STS enforce/testing/cache and MX wildcard policy through BIND and Technitium authorities | [PASS](test-evidence/0.7-r1.md#smtp-27) | | | +| SMTP-27 | DANE takes precedence; MTA-STS enforce/testing/cache and MX wildcard policy through complete BIND/Unbound and Technitium/Technitium DNS stacks | [PASS](test-evidence/0.7-r1.md#smtp-27) | | | | SMTP-09 | Relayhost hostname, port, TLS, authentication, failure, and recovery | | | | | SMTP-10 | Open-relay tests reject unauthenticated/untrusted third-party relay | | | | | SMTP-11 | `SIZE` exact limit, over-limit, absent size, and disk-space responses | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 071c5f0..0437ca4 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2435,16 +2435,23 @@ local configuration mirror, and active service state. Result: **PASS** The reusable `smtp-dane-mta-sts-check.py` fixture creates an isolated, -DNSSEC-signed private zone, a validating Unbound resolver, eight loopback -SMTP destinations, and an HTTPS MTA-STS policy server. The same unsigned zone -is served and signed in two independent runs: first by BIND and then by -Technitium DNS Server 15.4. Technitium imports the records and creates its -ECDSA P-256/NSEC keys through its HTTP API. The published KSK from the -selected authority becomes the temporary Unbound and Bongo trust anchor. -Both runs therefore exercise an identical DNSSEC-validation boundary and -only replace the authoritative DNS implementation. The fixture submits -uniquely marked messages through Bongo's trusted listener and observes the -real Queue and `bongosmtpc` direct-MX path. +DNSSEC-signed private zone, eight loopback SMTP destinations, and an HTTPS +MTA-STS policy server. It runs the same Bongo delivery matrix through two +complete and independent DNS stacks: + +- BIND signs and serves the fixture zone while Unbound performs validation; +- one Technitium DNS Server 15.4 instance signs and serves a synthetic root + and the delegated fixture zone, while a second Technitium instance starts + with isolated root hints and the synthetic root trust anchor and performs + recursive DNSSEC validation. + +The Technitium authority imports the records and creates ECDSA P-256/NSEC +keys through its HTTP API. Its signed root delegates `smtp27.bongo-test` with +the published child DS. Before Bongo starts, the test requires the recursive +Technitium instance to return the fixture MX and its RRSIG with the AD bit. +The selected zone KSK also becomes Bongo's temporary direct DANE trust +anchor. The fixture submits uniquely marked messages through Bongo's trusted +listener and observes the real Queue and `bongosmtpc` direct-MX path. The live debugging exposed and fixed three production defects: @@ -2472,11 +2479,11 @@ ctest --test-dir /tmp/bongo-smtp27-strict3 --output-on-failure \ -R '^(xpl-dns-mx|smtp-dane|smtp-mta-sts)$' ``` -The installed debug build then reported for both authoritative servers: +The installed debug build then reported for both complete DNS stacks: ```text SMTP-27 PASS dane=untrusted-ee/mismatch-deferred/precedence mta-sts=enforce/testing/mx-mismatch/cache dnssec=validated resolver=unbound authority=bind queue-clean=yes service-restored=active -SMTP-27 PASS dane=untrusted-ee/mismatch-deferred/precedence mta-sts=enforce/testing/mx-mismatch/cache dnssec=validated resolver=unbound authority=technitium queue-clean=yes service-restored=active +SMTP-27 PASS dane=untrusted-ee/mismatch-deferred/precedence mta-sts=enforce/testing/mx-mismatch/cache dnssec=validated resolver=technitium authority=technitium queue-clean=yes service-restored=active ``` The DANE matrix delivered through an untrusted certificate only when its @@ -2489,9 +2496,10 @@ delivery after the HTTPS policy service stopped, and enforce mode against a plaintext peer. The fixture removed every marked Queue entry and all temporary keys, -certificates, BIND, Technitium, and Unbound processes, trust anchors, cache -data, and backup files. It restored the original resolver bytes and SMTP -Store document. Technitium used a disposable configuration directory; its -system service stayed disabled and `/etc/dns` was not modified. The final -resolver was the NetworkManager configuration, `bongo.service` was active, -and the journal contained no post-fix `bongosmtpc` coredump. +certificates, BIND, both Technitium instances, and Unbound processes, trust +anchors, copied runtime data, cache data, and backup files. It restored the +original resolver bytes and SMTP Store document. Technitium used disposable +configuration directories; its system service stayed disabled and `/etc/dns` +was not modified. The final resolver was the NetworkManager configuration, +`bongo.service` was active, and the journal contained no post-fix +`bongosmtpc` coredump.