Test complete Technitium DNSSEC stack

This commit is contained in:
Mario Fetka
2026-07-26 20:38:42 +02:00
parent 620786b39f
commit b9eeb545c3
4 changed files with 390 additions and 113 deletions
+16 -16
View File
@@ -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
+347 -78
View File
@@ -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(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<TrustAnchor id="BONGO-SMTP-27" source="urn:bongo:smtp27">\n'
' <Zone>.</Zone>\n'
' <KeyDigest id="BONGO-SMTP-27-KSK" '
'validFrom="2000-01-01T00:00:00+00:00">\n'
f" <KeyTag>{key_tag}</KeyTag>\n"
f" <Algorithm>{algorithm}</Algorithm>\n"
f" <DigestType>{digest_type}</DigestType>\n"
f" <Digest>{digest}</Digest>\n"
" </KeyDigest>\n"
"</TrustAnchor>\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
+1 -1
View File
@@ -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 | | | |
+26 -18
View File
@@ -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.