Test complete Technitium DNSSEC stack
This commit is contained in:
+16
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user