Add a signed DANE and MTA-STS live fixture
Debian Trixie package bundle / packages (push) Failing after 14m36s
Debian Trixie package bundle / packages (push) Failing after 14m36s
This commit is contained in:
@@ -65,9 +65,19 @@ one-off files in `/tmp`:
|
||||
globally required TLS never falls back after absent or failed STARTTLS. A
|
||||
second phase injects RFC 8689 `REQUIRETLS` over an encrypted trusted
|
||||
listener and verifies its Queue trace header, outbound `MAIL FROM`
|
||||
parameter, encrypted delivery to a capable peer, and deferral when a peer
|
||||
lacks TLS or `REQUIRETLS`. It has the same resolver, Queue, configuration,
|
||||
and service restoration guarantees as the opportunistic check.
|
||||
parameter, encrypted delivery to a capable peer, and non-delivery when a
|
||||
peer lacks TLS or `REQUIRETLS`. It has the same resolver, Queue,
|
||||
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, validating Unbound resolver, HTTPS policy
|
||||
host, and eight loopback MX fixtures. It proves DANE-EE delivery 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.
|
||||
- `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
|
||||
|
||||
Executable
+853
@@ -0,0 +1,853 @@
|
||||
#!/usr/bin/env python3
|
||||
# This program is free software, licensed under the terms of the GNU GPL.
|
||||
# See the Bongo COPYING file for full details.
|
||||
# Copyright (c) 2026 Bongo Project contributors
|
||||
|
||||
"""Exercise DANE and MTA-STS with a disposable signed DNS zone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import copy
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import http.server
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pwd
|
||||
import shutil
|
||||
import signal
|
||||
import smtplib
|
||||
import socket
|
||||
import socketserver
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
TOKEN = f"smtp27-{os.getpid()}-{int(time.time())}"
|
||||
ZONE = "smtp27.test"
|
||||
DNS_RESOLVER = "127.0.0.53"
|
||||
DNS_AUTHORITY = "127.0.0.54"
|
||||
DNS_AUTHORITY_PORT = 5353
|
||||
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"))
|
||||
RESOLV_BACKUP = Path("/run/bongo-smtp27-resolv.conf.backup")
|
||||
ANCHOR_FILE = Path("/etc/dnssec/root-anchors.txt")
|
||||
ANCHOR_BACKUP = Path("/run/bongo-smtp27-root-anchors.backup")
|
||||
PKI_ROOT = Path("/var/lib/bongo-test-pki")
|
||||
PKI_TOOL = Path(__file__).with_name("local-test-pki.py")
|
||||
WORK_ROOT = Path("/var/lib/bongo/work")
|
||||
|
||||
|
||||
class SMTP27Error(RuntimeError):
|
||||
"""Raised when a DANE or MTA-STS invariant is violated."""
|
||||
|
||||
|
||||
def load_smtp07_helpers():
|
||||
path = Path(__file__).with_name(
|
||||
"smtp-outbound-opportunistic-tls-check.py")
|
||||
specification = importlib.util.spec_from_file_location(
|
||||
"bongo_smtp27_helpers", path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise SMTP27Error(f"cannot load fixture helpers from {path}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[specification.name] = module
|
||||
specification.loader.exec_module(module)
|
||||
module.TOKEN = TOKEN
|
||||
module.RESOLV_BACKUP = RESOLV_BACKUP
|
||||
return module
|
||||
|
||||
|
||||
SMTP07 = load_smtp07_helpers()
|
||||
|
||||
|
||||
def run(
|
||||
arguments: list[str | Path],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
input_text: str | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
command = [str(argument) for argument in arguments]
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
input=input_text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and completed.returncode:
|
||||
raise SMTP27Error(
|
||||
f"{' '.join(command)} failed: "
|
||||
f"{completed.stderr.strip() or completed.stdout.strip()}")
|
||||
return completed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
name: str
|
||||
address: str
|
||||
tls_kind: str | None
|
||||
sts_mode: str | None = None
|
||||
sts_mx: str | None = None
|
||||
tlsa: str | None = None
|
||||
|
||||
@property
|
||||
def domain(self) -> str:
|
||||
return f"{self.name}.{ZONE}"
|
||||
|
||||
@property
|
||||
def mx(self) -> str:
|
||||
return f"mx-{self.name}.{ZONE}"
|
||||
|
||||
@property
|
||||
def policy_host(self) -> str:
|
||||
return f"mta-sts.{self.domain}"
|
||||
|
||||
|
||||
ROUTES = (
|
||||
Route("dane-good", "127.0.0.2", "untrusted", tlsa="valid"),
|
||||
Route("dane-bad", "127.0.0.3", "untrusted", tlsa="invalid"),
|
||||
Route("sts-enforce", "127.0.0.4", "trusted",
|
||||
sts_mode="enforce"),
|
||||
Route("sts-testing", "127.0.0.5", None,
|
||||
sts_mode="testing"),
|
||||
Route("sts-mismatch", "127.0.0.6", "trusted",
|
||||
sts_mode="enforce", sts_mx="other.smtp27.test"),
|
||||
Route("dane-precedence", "127.0.0.7", "trusted",
|
||||
sts_mode="enforce", tlsa="invalid"),
|
||||
Route("sts-cache", "127.0.0.8", "trusted",
|
||||
sts_mode="enforce", sts_mx="*.smtp27.test"),
|
||||
Route("sts-plain", "127.0.0.10", None,
|
||||
sts_mode="enforce"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Capture:
|
||||
route: Route
|
||||
tls_context: ssl.SSLContext | None
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
connections: int = 0
|
||||
starttls: int = 0
|
||||
messages: list[tuple[bool, bytes]] = field(default_factory=list)
|
||||
|
||||
def connected(self) -> None:
|
||||
with self.lock:
|
||||
self.connections += 1
|
||||
|
||||
def tls_started(self) -> None:
|
||||
with self.lock:
|
||||
self.starttls += 1
|
||||
|
||||
def delivered(self, encrypted: bool, message: bytes) -> None:
|
||||
with self.lock:
|
||||
self.messages.append((encrypted, message))
|
||||
|
||||
def snapshot(self) -> tuple[int, int, list[tuple[bool, bytes]]]:
|
||||
with self.lock:
|
||||
return self.connections, self.starttls, list(self.messages)
|
||||
|
||||
|
||||
class SMTPHandler(socketserver.BaseRequestHandler):
|
||||
def handle(self) -> None:
|
||||
capture: Capture = self.server.capture # type: ignore[attr-defined]
|
||||
capture.connected()
|
||||
connection = self.request
|
||||
connection.settimeout(15)
|
||||
encrypted = False
|
||||
data_mode = False
|
||||
message = bytearray()
|
||||
buffer = bytearray()
|
||||
|
||||
def send(line: bytes) -> None:
|
||||
connection.sendall(line + b"\r\n")
|
||||
|
||||
def read_line() -> bytes | None:
|
||||
while b"\n" not in buffer:
|
||||
chunk = connection.recv(8192)
|
||||
if not chunk:
|
||||
return None
|
||||
buffer.extend(chunk)
|
||||
if len(buffer) > 4 * 1024 * 1024:
|
||||
raise SMTP27Error("fixture SMTP input exceeded limit")
|
||||
end = buffer.index(b"\n") + 1
|
||||
line = bytes(buffer[:end])
|
||||
del buffer[:end]
|
||||
return line.rstrip(b"\r\n")
|
||||
|
||||
send(f"220 {capture.route.mx} ESMTP smtp27".encode("ascii"))
|
||||
while True:
|
||||
try:
|
||||
line = read_line()
|
||||
except (OSError, SMTP27Error):
|
||||
return
|
||||
if line is None:
|
||||
return
|
||||
if data_mode:
|
||||
if line == b".":
|
||||
capture.delivered(encrypted, bytes(message))
|
||||
send(b"250 2.0.0 captured")
|
||||
data_mode = False
|
||||
message.clear()
|
||||
else:
|
||||
if line.startswith(b".."):
|
||||
line = line[1:]
|
||||
message.extend(line + b"\r\n")
|
||||
continue
|
||||
|
||||
command, _, _argument = line.partition(b" ")
|
||||
command = command.upper()
|
||||
if command in (b"EHLO", b"HELO"):
|
||||
if command == b"EHLO":
|
||||
send(f"250-{capture.route.mx}".encode("ascii"))
|
||||
if not encrypted and capture.tls_context is not None:
|
||||
send(b"250-STARTTLS")
|
||||
send(b"250 SIZE 10485760")
|
||||
else:
|
||||
send(f"250 {capture.route.mx}".encode("ascii"))
|
||||
elif command == b"STARTTLS":
|
||||
capture.tls_started()
|
||||
if encrypted or capture.tls_context is None:
|
||||
send(b"454 4.7.0 TLS unavailable")
|
||||
continue
|
||||
send(b"220 2.0.0 begin TLS")
|
||||
try:
|
||||
connection = capture.tls_context.wrap_socket(
|
||||
connection, server_side=True)
|
||||
connection.settimeout(15)
|
||||
buffer.clear()
|
||||
encrypted = True
|
||||
except ssl.SSLError:
|
||||
return
|
||||
elif command == b"MAIL":
|
||||
send(b"250 2.1.0 sender accepted")
|
||||
elif command == b"RCPT":
|
||||
send(b"250 2.1.5 recipient accepted")
|
||||
elif command == b"DATA":
|
||||
send(b"354 end with <CRLF>.<CRLF>")
|
||||
data_mode = True
|
||||
message.clear()
|
||||
elif command == b"RSET":
|
||||
data_mode = False
|
||||
message.clear()
|
||||
send(b"250 2.0.0 reset")
|
||||
elif command == b"NOOP":
|
||||
send(b"250 2.0.0 ok")
|
||||
elif command == b"QUIT":
|
||||
send(b"221 2.0.0 bye")
|
||||
return
|
||||
else:
|
||||
send(b"502 5.5.1 command not implemented")
|
||||
|
||||
|
||||
class ThreadingTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class PolicyServer(http.server.ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
|
||||
class PolicyHandler(http.server.BaseHTTPRequestHandler):
|
||||
server_version = "BongoSMTP27/1"
|
||||
|
||||
def do_GET(self) -> None:
|
||||
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
||||
policy = self.server.policies.get(host) # type: ignore[attr-defined]
|
||||
if self.path != "/.well-known/mta-sts.txt" or policy is None:
|
||||
self.send_error(404)
|
||||
return
|
||||
self.server.requests.append(host) # type: ignore[attr-defined]
|
||||
payload = policy.encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, _format: str, *_arguments) -> None:
|
||||
return
|
||||
|
||||
|
||||
def certificate_context(directory: Path) -> ssl.SSLContext:
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.load_cert_chain(directory / "fullchain.pem",
|
||||
directory / "key.pem")
|
||||
return context
|
||||
|
||||
|
||||
def certificate_sha256(certificate: Path) -> str:
|
||||
pem = certificate.read_text(encoding="ascii")
|
||||
der = ssl.PEM_cert_to_DER_cert(pem)
|
||||
return hashlib.sha256(der).hexdigest()
|
||||
|
||||
|
||||
def issue_certificates() -> tuple[Path, Path, list[Path]]:
|
||||
trusted_name = f"smtp27-trusted-{os.getpid()}"
|
||||
untrusted_name = f"smtp27-untrusted-{os.getpid()}"
|
||||
trusted_names = [
|
||||
route.mx for route in ROUTES if route.tls_kind == "trusted"
|
||||
] + [
|
||||
route.policy_host for route in ROUTES if route.sts_mode is not None
|
||||
]
|
||||
untrusted_names = [
|
||||
route.mx for route in ROUTES if route.tls_kind == "untrusted"
|
||||
]
|
||||
commands = (
|
||||
["issue", "--ca", "trusted", "--name", trusted_name],
|
||||
["issue", "--ca", "untrusted", "--name", untrusted_name],
|
||||
)
|
||||
for command, names in zip(commands, (trusted_names, untrusted_names)):
|
||||
arguments: list[str | Path] = [PKI_TOOL, *command]
|
||||
for name in names:
|
||||
arguments.extend(["--dns-name", name])
|
||||
run(arguments)
|
||||
trusted = PKI_ROOT / "issued" / trusted_name
|
||||
untrusted = PKI_ROOT / "issued" / untrusted_name
|
||||
return trusted, untrusted, [trusted, untrusted]
|
||||
|
||||
|
||||
def write_zone(directory: Path, valid_tlsa: str) -> str:
|
||||
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}",
|
||||
]
|
||||
for route in ROUTES:
|
||||
lines.extend([
|
||||
f"{route.name} IN MX 10 {route.mx}.",
|
||||
f"mx-{route.name} IN A {route.address}",
|
||||
])
|
||||
if route.sts_mode is not None:
|
||||
lines.extend([
|
||||
(f'_mta-sts.{route.name} IN TXT '
|
||||
f'"v=STSv1; id={TOKEN.replace("-", "")};"'),
|
||||
f"mta-sts.{route.name} IN A {HTTPS_ADDRESS}",
|
||||
])
|
||||
if route.tlsa is not None:
|
||||
digest = valid_tlsa if route.tlsa == "valid" else "00" * 32
|
||||
lines.append(
|
||||
f"_25._tcp.mx-{route.name} IN TLSA 3 0 1 {digest}")
|
||||
(directory / "zone").write_text("\n".join(lines) + "\n",
|
||||
encoding="ascii")
|
||||
run([
|
||||
"dnssec-keygen", "-q", "-K", ".", "-a", "ECDSAP256SHA256",
|
||||
"-n", "ZONE", ZONE,
|
||||
], cwd=directory)
|
||||
run([
|
||||
"dnssec-keygen", "-q", "-K", ".", "-a", "ECDSAP256SHA256",
|
||||
"-f", "KSK", "-n", "ZONE", ZONE,
|
||||
], cwd=directory)
|
||||
run([
|
||||
"dnssec-signzone", "-S", "-K", ".", "-o", ZONE,
|
||||
"-f", "zone.signed", "zone",
|
||||
], cwd=directory)
|
||||
for key_file in directory.glob(f"K{ZONE}.*.key"):
|
||||
for line in key_file.read_text(encoding="ascii").splitlines():
|
||||
if " DNSKEY 257 " in line:
|
||||
return " ".join(line.split())
|
||||
raise SMTP27Error("dnssec-keygen did not produce a KSK trust anchor")
|
||||
|
||||
|
||||
def write_dns_configs(directory: Path, trust_anchor: str) -> None:
|
||||
(directory / "named.conf").write_text(
|
||||
"options {\n"
|
||||
f' directory "{directory}";\n'
|
||||
f" listen-on port {DNS_AUTHORITY_PORT} {{ {DNS_AUTHORITY}; }};\n"
|
||||
" listen-on-v6 { none; };\n"
|
||||
" recursion no;\n"
|
||||
" dnssec-validation no;\n"
|
||||
f' pid-file "{directory / "named.pid"}";\n'
|
||||
f' session-keyfile "{directory / "session.key"}";\n'
|
||||
"};\n"
|
||||
"controls {};\n"
|
||||
f'zone "{ZONE}" {{ type primary; file "zone.signed"; }};\n',
|
||||
encoding="ascii",
|
||||
)
|
||||
(directory / "anchor").write_text(trust_anchor + "\n",
|
||||
encoding="ascii")
|
||||
(directory / "unbound.conf").write_text(
|
||||
"server:\n"
|
||||
f" interface: {DNS_RESOLVER}@53\n"
|
||||
" do-ip4: yes\n"
|
||||
" do-ip6: no\n"
|
||||
' username: ""\n'
|
||||
f' directory: "{directory}"\n'
|
||||
f' pidfile: "{directory / "unbound.pid"}"\n'
|
||||
f' logfile: "{directory / "unbound.log"}"\n'
|
||||
" use-syslog: no\n"
|
||||
f' trust-anchor-file: "{directory / "anchor"}"\n'
|
||||
"stub-zone:\n"
|
||||
f' name: "{ZONE}."\n'
|
||||
f" stub-addr: {DNS_AUTHORITY}@{DNS_AUTHORITY_PORT}\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
|
||||
|
||||
def start_process(arguments: list[str], log: Path) -> subprocess.Popen:
|
||||
stream = log.open("wb")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
stdout=stream,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
finally:
|
||||
stream.close()
|
||||
return process
|
||||
|
||||
|
||||
def stop_process(process: subprocess.Popen | None) -> None:
|
||||
if process is None or process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
def wait_for_dns() -> None:
|
||||
deadline = time.monotonic() + 15
|
||||
while time.monotonic() < deadline:
|
||||
result = run([
|
||||
"dig", f"@{DNS_RESOLVER}", "+dnssec", "+adflag",
|
||||
"+time=1", "+tries=1", "MX", f"dane-good.{ZONE}",
|
||||
], check=False)
|
||||
if (result.returncode == 0 and "status: NOERROR" in result.stdout
|
||||
and " ad;" in result.stdout):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise SMTP27Error("local validating DNSSEC resolver did not become ready")
|
||||
|
||||
|
||||
def start_mail_servers(
|
||||
trusted_context: ssl.SSLContext,
|
||||
untrusted_context: ssl.SSLContext,
|
||||
) -> tuple[list[ThreadingTCPServer], dict[str, Capture]]:
|
||||
servers: list[ThreadingTCPServer] = []
|
||||
captures: dict[str, Capture] = {}
|
||||
for route in ROUTES:
|
||||
context = {
|
||||
"trusted": trusted_context,
|
||||
"untrusted": untrusted_context,
|
||||
None: None,
|
||||
}[route.tls_kind]
|
||||
capture = Capture(route, context)
|
||||
server = ThreadingTCPServer((route.address, 25), SMTPHandler)
|
||||
server.capture = capture # type: ignore[attr-defined]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
servers.append(server)
|
||||
captures[route.name] = capture
|
||||
return servers, captures
|
||||
|
||||
|
||||
def stop_server(server: socketserver.BaseServer | None) -> None:
|
||||
if server is None:
|
||||
return
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
|
||||
def policies() -> dict[str, str]:
|
||||
result = {}
|
||||
for route in ROUTES:
|
||||
if route.sts_mode is None:
|
||||
continue
|
||||
mx = route.sts_mx or route.mx
|
||||
result[route.policy_host] = (
|
||||
"version: STSv1\n"
|
||||
f"mode: {route.sts_mode}\n"
|
||||
f"mx: {mx}\n"
|
||||
"max_age: 86400\n"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def start_policy_server(
|
||||
trusted_context: ssl.SSLContext,
|
||||
) -> PolicyServer:
|
||||
server = PolicyServer((HTTPS_ADDRESS, 443), PolicyHandler)
|
||||
server.socket = trusted_context.wrap_socket(
|
||||
server.socket, server_side=True)
|
||||
server.policies = policies() # type: ignore[attr-defined]
|
||||
server.requests = [] # type: ignore[attr-defined]
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
def marker(route: Route, phase: str) -> str:
|
||||
return f"{TOKEN}-{phase}-{route.name}"
|
||||
|
||||
|
||||
def submit(route: Route, phase: str, internal_host: str) -> None:
|
||||
recipient = f"capture@{route.domain}"
|
||||
token = marker(route, phase)
|
||||
message = (
|
||||
f"From: SMTP-27 <smtp27@bongo.test>\r\n"
|
||||
f"To: {recipient}\r\n"
|
||||
f"Subject: {token}\r\n"
|
||||
f"Message-ID: <{token}@bongo.test>\r\n"
|
||||
"\r\n"
|
||||
f"{token}\r\n"
|
||||
).encode("ascii")
|
||||
with smtplib.SMTP(internal_host, SMTP07.INTERNAL_PORT,
|
||||
timeout=10) as client:
|
||||
client.ehlo("smtp27-client.bongo.test")
|
||||
refused = client.sendmail(
|
||||
"smtp27@bongo.test", [recipient], message)
|
||||
if refused:
|
||||
raise SMTP27Error(f"Bongo refused {recipient}: {refused}")
|
||||
|
||||
|
||||
def matching_messages(
|
||||
capture: Capture, route: Route, phase: str
|
||||
) -> list[tuple[bool, bytes]]:
|
||||
token = marker(route, phase).encode("ascii")
|
||||
return [
|
||||
item for item in capture.snapshot()[2]
|
||||
if token in item[1]
|
||||
]
|
||||
|
||||
|
||||
def queued_tokens(expected: set[str]) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for queue_id in SMTP07.queue_ids():
|
||||
message = SMTP07.queue("message", queue_id, check=False)
|
||||
if message.returncode:
|
||||
continue
|
||||
for token in expected:
|
||||
if token in message.stdout:
|
||||
found.add(token)
|
||||
return found
|
||||
|
||||
|
||||
def wait_until(predicate, description: str) -> None:
|
||||
deadline = time.monotonic() + TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
time.sleep(1)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise SMTP27Error(f"timeout waiting for {description}")
|
||||
|
||||
|
||||
def validate_initial(
|
||||
captures: dict[str, Capture],
|
||||
) -> set[str]:
|
||||
success = ("dane-good", "sts-enforce", "sts-testing", "sts-cache")
|
||||
failure = ("dane-bad", "sts-mismatch", "dane-precedence", "sts-plain")
|
||||
queued = {
|
||||
marker(next(route for route in ROUTES if route.name == name),
|
||||
"initial")
|
||||
for name in failure
|
||||
}
|
||||
wait_until(
|
||||
lambda: (
|
||||
all(matching_messages(
|
||||
captures[name],
|
||||
next(route for route in ROUTES if route.name == name),
|
||||
"initial") for name in success)
|
||||
and queued_tokens(queued) == queued
|
||||
),
|
||||
"DANE and MTA-STS initial outcomes",
|
||||
)
|
||||
for name in success:
|
||||
route = next(route for route in ROUTES if route.name == name)
|
||||
messages = matching_messages(captures[name], route, "initial")
|
||||
if len(messages) != 1:
|
||||
raise SMTP27Error(
|
||||
f"{name}: expected one delivery, got {len(messages)}")
|
||||
expected_tls = name != "sts-testing"
|
||||
if messages[0][0] is not expected_tls:
|
||||
raise SMTP27Error(
|
||||
f"{name}: expected encrypted={expected_tls}, "
|
||||
f"got {messages[0][0]}")
|
||||
for name in failure:
|
||||
route = next(route for route in ROUTES if route.name == name)
|
||||
if matching_messages(captures[name], route, "initial"):
|
||||
raise SMTP27Error(f"{name}: policy failure delivered a message")
|
||||
return queued
|
||||
|
||||
|
||||
def validate_cached_policy(
|
||||
captures: dict[str, Capture], route: Route
|
||||
) -> None:
|
||||
wait_until(
|
||||
lambda: len(matching_messages(
|
||||
captures[route.name], route, "cached")) == 1,
|
||||
"MTA-STS cached-policy delivery",
|
||||
)
|
||||
messages = matching_messages(captures[route.name], route, "cached")
|
||||
if messages[0][0] is not True:
|
||||
raise SMTP27Error("cached MTA-STS policy delivered without TLS")
|
||||
|
||||
|
||||
def append_trust_anchor(anchor: str) -> None:
|
||||
if ANCHOR_BACKUP.exists():
|
||||
raise SMTP27Error(
|
||||
f"stale DNS trust-anchor backup at {ANCHOR_BACKUP}")
|
||||
metadata = SMTP07.regular_file_metadata(ANCHOR_FILE)
|
||||
original = ANCHOR_FILE.read_bytes()
|
||||
backup_file(ANCHOR_BACKUP, original)
|
||||
payload = original
|
||||
if payload and not payload.endswith(b"\n"):
|
||||
payload += b"\n"
|
||||
payload += (anchor + "\n").encode("ascii")
|
||||
SMTP07.atomic_write(ANCHOR_FILE, payload, metadata)
|
||||
|
||||
|
||||
def backup_file(path: Path, payload: bytes) -> None:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def restore_file(path: Path, backup: Path) -> None:
|
||||
if not backup.exists():
|
||||
return
|
||||
metadata = SMTP07.regular_file_metadata(path)
|
||||
SMTP07.atomic_write(path, backup.read_bytes(), metadata)
|
||||
backup.unlink()
|
||||
|
||||
|
||||
def perform_test() -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise SMTP27Error("the live DNSSEC fixture must run as root")
|
||||
if RESOLV_BACKUP.exists() or ANCHOR_BACKUP.exists():
|
||||
raise SMTP27Error(
|
||||
"stale SMTP-27 backup exists; run --restore before testing")
|
||||
if not SMTP07.service_active():
|
||||
raise SMTP27Error("bongo.service must be active before this test")
|
||||
if not ANCHOR_FILE.is_file():
|
||||
raise SMTP27Error(f"DNS root trust-anchor file missing: {ANCHOR_FILE}")
|
||||
if not (PKI_ROOT / "trusted-ca.crt").is_file():
|
||||
raise SMTP27Error(
|
||||
"local test PKI is not prepared; run local-test-pki.py install")
|
||||
|
||||
original_smtp = SMTP07.read_smtp_configuration()
|
||||
if original_smtp.get("use_relay_host"):
|
||||
raise SMTP27Error(
|
||||
"SMTP-27 requires direct MX delivery, but relay_host is enabled")
|
||||
internal_host = str(original_smtp.get(
|
||||
"internal_relay_bind_address", "")).strip()
|
||||
if internal_host in ("", "0.0.0.0", "::"):
|
||||
raise SMTP27Error(
|
||||
"set BONGO_TEST_INTERNAL_HOST for a wildcard trusted listener")
|
||||
SMTP07.INTERNAL_HOST = internal_host
|
||||
original_resolver = SMTP07.RESOLV_CONF.read_bytes()
|
||||
resolver_metadata = SMTP07.regular_file_metadata(SMTP07.RESOLV_CONF)
|
||||
original_active = SMTP07.service_active()
|
||||
smtp_changed = False
|
||||
resolver_changed = False
|
||||
anchor_changed = False
|
||||
dns_directory: Path | None = None
|
||||
certificate_directories: list[Path] = []
|
||||
named_process: subprocess.Popen | None = None
|
||||
unbound_process: subprocess.Popen | None = None
|
||||
mail_servers: list[ThreadingTCPServer] = []
|
||||
policy_server: PolicyServer | None = None
|
||||
primary_error: BaseException | None = None
|
||||
|
||||
def terminate(_signum: int, _frame) -> None:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
signal.signal(signal.SIGTERM, terminate)
|
||||
signal.signal(signal.SIGHUP, terminate)
|
||||
try:
|
||||
trusted, untrusted, certificate_directories = issue_certificates()
|
||||
trusted_context = certificate_context(trusted)
|
||||
untrusted_context = certificate_context(untrusted)
|
||||
valid_tlsa = certificate_sha256(untrusted / "cert.pem")
|
||||
|
||||
bongo_user = pwd.getpwnam("bongo")
|
||||
dns_directory = Path(tempfile.mkdtemp(
|
||||
prefix="smtp27-", dir=WORK_ROOT))
|
||||
dns_directory.chmod(0o755)
|
||||
cache_directory = dns_directory / "mta-sts-cache"
|
||||
cache_directory.mkdir(mode=0o750)
|
||||
os.chown(cache_directory, bongo_user.pw_uid, bongo_user.pw_gid)
|
||||
trust_anchor = write_zone(dns_directory, valid_tlsa)
|
||||
write_dns_configs(dns_directory, trust_anchor)
|
||||
|
||||
test_smtp = copy.deepcopy(original_smtp)
|
||||
test_smtp["port"] = SMTP07.TEMPORARY_PUBLIC_PORT
|
||||
test_smtp["outbound_tls_verify"] = False
|
||||
test_smtp["outbound_tls_required"] = False
|
||||
test_smtp["outbound_dane_enabled"] = True
|
||||
test_smtp["outbound_mta_sts_enabled"] = True
|
||||
test_smtp["outbound_mta_sts_cache_directory"] = str(cache_directory)
|
||||
SMTP07.replace_smtp_configuration(test_smtp)
|
||||
smtp_changed = True
|
||||
run([SMTP07.SYSTEMCTL, "stop", "bongo.service"])
|
||||
|
||||
SMTP07.save_resolver(original_resolver)
|
||||
SMTP07.atomic_write(
|
||||
SMTP07.RESOLV_CONF,
|
||||
(
|
||||
"# Temporary Bongo SMTP-27 validating resolver\n"
|
||||
f"nameserver {DNS_RESOLVER}\n"
|
||||
"options timeout:1 attempts:2\n"
|
||||
).encode("ascii"),
|
||||
resolver_metadata,
|
||||
)
|
||||
resolver_changed = True
|
||||
append_trust_anchor(trust_anchor)
|
||||
anchor_changed = True
|
||||
|
||||
named_process = start_process(
|
||||
["named", "-g", "-c", str(dns_directory / "named.conf")],
|
||||
dns_directory / "named.log",
|
||||
)
|
||||
unbound_process = start_process(
|
||||
["unbound", "-d", "-c", str(dns_directory / "unbound.conf")],
|
||||
dns_directory / "unbound-process.log",
|
||||
)
|
||||
wait_for_dns()
|
||||
mail_servers, captures = start_mail_servers(
|
||||
trusted_context, untrusted_context)
|
||||
policy_server = start_policy_server(trusted_context)
|
||||
|
||||
run([SMTP07.SYSTEMCTL, "start", "bongo.service"])
|
||||
SMTP07.wait_for_bongo()
|
||||
time.sleep(STARTUP_SETTLE)
|
||||
for route in ROUTES:
|
||||
submit(route, "initial", internal_host)
|
||||
failed_tokens = validate_initial(captures)
|
||||
|
||||
cache_route = next(
|
||||
route for route in ROUTES if route.name == "sts-cache")
|
||||
requests_before = list(policy_server.requests) # type: ignore[attr-defined]
|
||||
stop_server(policy_server)
|
||||
policy_server = None
|
||||
submit(cache_route, "cached", internal_host)
|
||||
validate_cached_policy(captures, cache_route)
|
||||
if requests_before.count(cache_route.policy_host) != 1:
|
||||
raise SMTP27Error(
|
||||
"MTA-STS cache route did not fetch exactly one policy")
|
||||
if queued_tokens(failed_tokens) != failed_tokens:
|
||||
raise SMTP27Error("a deferred policy failure disappeared from Queue")
|
||||
except BaseException as error:
|
||||
primary_error = error
|
||||
finally:
|
||||
restoration_errors: list[str] = []
|
||||
if policy_server is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
stop_server(policy_server)
|
||||
for server in mail_servers:
|
||||
with contextlib.suppress(OSError):
|
||||
stop_server(server)
|
||||
stop_process(unbound_process)
|
||||
stop_process(named_process)
|
||||
|
||||
if not SMTP07.service_active():
|
||||
try:
|
||||
run([SMTP07.SYSTEMCTL, "start", "bongo.service"])
|
||||
SMTP07.wait_for_bongo()
|
||||
except (OSError, RuntimeError) as error:
|
||||
restoration_errors.append(
|
||||
f"could not start Bongo for cleanup: {error}")
|
||||
if SMTP07.service_active():
|
||||
try:
|
||||
SMTP07.cleanup_queue()
|
||||
except (OSError, RuntimeError) as error:
|
||||
restoration_errors.append(f"queue cleanup failed: {error}")
|
||||
if smtp_changed:
|
||||
try:
|
||||
SMTP07.replace_smtp_configuration(original_smtp)
|
||||
smtp_changed = False
|
||||
except (OSError, RuntimeError) as error:
|
||||
restoration_errors.append(
|
||||
f"SMTP configuration restore failed: {error}")
|
||||
if run([SMTP07.SYSTEMCTL, "stop", "bongo.service"],
|
||||
check=False).returncode:
|
||||
restoration_errors.append("could not stop bongo.service")
|
||||
try:
|
||||
if resolver_changed:
|
||||
restore_file(SMTP07.RESOLV_CONF, RESOLV_BACKUP)
|
||||
resolver_changed = False
|
||||
if anchor_changed:
|
||||
restore_file(ANCHOR_FILE, ANCHOR_BACKUP)
|
||||
anchor_changed = False
|
||||
except OSError as error:
|
||||
restoration_errors.append(f"DNS configuration restore failed: {error}")
|
||||
if dns_directory is not None:
|
||||
shutil.rmtree(dns_directory, ignore_errors=True)
|
||||
for directory in certificate_directories:
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
if original_active:
|
||||
try:
|
||||
run([SMTP07.SYSTEMCTL, "start", "bongo.service"])
|
||||
SMTP07.wait_for_bongo()
|
||||
except (OSError, RuntimeError) as error:
|
||||
restoration_errors.append(
|
||||
f"service state restore failed: {error}")
|
||||
if restoration_errors:
|
||||
cleanup_error = "; ".join(restoration_errors)
|
||||
if primary_error is not None:
|
||||
raise SMTP27Error(
|
||||
f"{primary_error}; cleanup: {cleanup_error}"
|
||||
) from primary_error
|
||||
raise SMTP27Error(cleanup_error)
|
||||
|
||||
if primary_error is not None:
|
||||
raise primary_error
|
||||
print(
|
||||
"SMTP-27 PASS "
|
||||
"dane=untrusted-ee/mismatch-deferred/precedence "
|
||||
"mta-sts=enforce/testing/mx-mismatch/cache "
|
||||
"dnssec=validated queue-clean=yes "
|
||||
f"service-restored={'active' if original_active else 'inactive'}"
|
||||
)
|
||||
|
||||
|
||||
def restore_only() -> None:
|
||||
if os.geteuid() != 0:
|
||||
raise SMTP27Error("restore must run as root")
|
||||
restore_file(SMTP07.RESOLV_CONF, RESOLV_BACKUP)
|
||||
restore_file(ANCHOR_FILE, ANCHOR_BACKUP)
|
||||
print("restored SMTP-27 resolver and trust-anchor files")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
action = parser.add_mutually_exclusive_group(required=True)
|
||||
action.add_argument("--live", action="store_true")
|
||||
action.add_argument("--restore", action="store_true")
|
||||
arguments = parser.parse_args()
|
||||
try:
|
||||
if arguments.restore:
|
||||
restore_only()
|
||||
else:
|
||||
perform_test()
|
||||
except (OSError, RuntimeError, smtplib.SMTPException) as error:
|
||||
print(f"smtp-dane-mta-sts-check: {error}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user