Cover mail DNS setup integration
Debian Trixie package bundle / packages (push) Failing after 12m24s

This commit is contained in:
Mario Fetka
2026-07-22 17:07:58 +02:00
parent 5da4feb191
commit 7f1bbf8587
3 changed files with 203 additions and 1 deletions
+3
View File
@@ -32,6 +32,9 @@ one-off files in `/tmp`:
`store-diagnostics.py` preserve the remaining interactive and diagnostic
bring-up tests. Destructive live helpers require an explicit opt-in
environment variable.
- `mail-dns-live-check.py` logs in through the real Web and IMAPS interfaces
and verifies that setup produced both an actionable SPF/DKIM/DMARC task and
a forwardable administrator message without private-key material.
The complete mapping from the original one-off `/tmp` names to maintained
scripts is recorded in `tmp-script-map.md`.
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Verify the setup-created mail-DNS task and administrator message."""
from __future__ import annotations
import http.cookiejar
import imaplib
import json
import os
import ssl
import urllib.request
from email import policy
from email.parser import BytesParser
from urllib.parse import urlsplit
base_url = os.environ.get("BONGO_TEST_WEB_URL", "http://127.0.0.1:8080").rstrip("/")
user = os.environ.get("BONGO_TEST_USER", "admin")
password = os.environ.get("BONGO_TEST_PASSWORD", "")
domain = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
timeout = int(os.environ.get("BONGO_TEST_TIMEOUT", "90"))
if not password:
raise SystemExit("set BONGO_TEST_PASSWORD for the disposable administrator")
cookies = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookies))
request = urllib.request.Request(
base_url + "/api/session",
data=json.dumps({"user": user, "password": password}).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
with opener.open(request, timeout=timeout) as response:
if response.status != 201:
raise SystemExit(f"Web login returned HTTP {response.status}")
with opener.open(base_url + "/api/tasks?include_closed=1",
timeout=timeout) as response:
tasks = json.load(response).get("tasks", [])
matching = [task for task in tasks
if task.get("type") == "mail-dns" and task.get("key") == domain]
if len(matching) != 1:
raise SystemExit(f"expected one mail-dns task for {domain}, got {len(matching)}")
task = matching[0]
if task.get("severity") != "warning" or not task.get("dismissible"):
raise SystemExit("mail-dns task does not have the expected actionable policy")
details = task.get("details", {})
if details.get("domain") != domain or not details.get("actions"):
raise SystemExit("mail-dns task has no actionable DNS details")
if set(details.get("suggested", {})) != {"spf", "dkim", "dmarc"}:
raise SystemExit("mail-dns task lacks an SPF, DKIM, or DMARC suggestion")
host = os.environ.get("BONGO_TEST_IMAP_HOST") or urlsplit(base_url).hostname
context = ssl.create_default_context()
if os.environ.get("BONGO_TEST_INSECURE_TLS") == "1":
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
with imaplib.IMAP4_SSL(
host, int(os.environ.get("BONGO_TEST_IMAPS_PORT", "993")),
ssl_context=context, timeout=timeout) as client:
client.login(user, password)
status, _count = client.select("INBOX", readonly=True)
if status != "OK":
raise SystemExit("cannot select administrator INBOX")
status, identifiers = client.search(
None, "HEADER", "X-Bongo-Notification", "mail-dns")
if status != "OK" or not identifiers or not identifiers[0].split():
raise SystemExit("administrator mail-DNS notification was not found")
identifier = identifiers[0].split()[-1]
status, data = client.fetch(identifier, "(BODY.PEEK[])")
if status != "OK":
raise SystemExit("cannot read administrator mail-DNS notification")
payload = next((item[1] for item in data
if isinstance(item, tuple) and isinstance(item[1], bytes)), b"")
message = BytesParser(policy=policy.default).parsebytes(payload)
if message.get("X-Bongo-Notification") != "mail-dns":
raise SystemExit("administrator message has the wrong notification marker")
if domain not in str(message.get("To", "")):
raise SystemExit("administrator message has the wrong recipient domain")
upper_payload = payload.upper()
if (b"-----BEGIN PRIVATE KEY-----" in upper_payload or
b"-----BEGIN RSA PRIVATE KEY-----" in upper_payload or
b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in upper_payload):
raise SystemExit("administrator message contains private-key material")
print("PASS actionable mail-DNS task and administrator notification")
+116 -1
View File
@@ -32,14 +32,18 @@ from unittest import mock
SOURCE = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(SOURCE / "src" / "libs" / "python"))
sys.path.insert(0, str(SOURCE / "src" / "www"))
from bongo.configuration.model import Scenario # noqa: E402
from bongo.configuration.security_material import ( # noqa: E402
DomainSecurityResult,
_private_key_data,
dns_notification_message,
inspect_mail_dns,
install_dns_tasks,
provision_security_material,
)
from bongo.configuration.storage import ConfigurationError # noqa: E402
class MailSecurityMaterialTest(unittest.TestCase):
@@ -60,7 +64,8 @@ class MailSecurityMaterialTest(unittest.TestCase):
self.assertIn(b"X-Bongo-Notification: mail-dns", message)
self.assertIn(b"bongo._domainkey.example.test", message)
self.assertIn(b"v=spf1 mx -all", message)
self.assertNotIn(b"PRIVATE KEY", message)
self.assertNotIn(b"-----BEGIN PRIVATE KEY-----", message)
self.assertNotIn(b"-----BEGIN RSA PRIVATE KEY-----", message)
def test_dns_inspection_selects_only_mail_authentication_records(self):
responses = [
@@ -79,6 +84,45 @@ class MailSecurityMaterialTest(unittest.TestCase):
self.assertEqual(result["example.test"]["dmarc"],
["v=DMARC1; p=none"])
def test_notification_is_empty_when_dns_has_no_remaining_work(self):
result = DomainSecurityResult(
"example.test", "bongo", {},
{"spf": "v=spf1 mx -all",
"dkim": "v=DKIM1; k=rsa; p=PUBLIC",
"dmarc": "v=DMARC1; p=none"}, ())
self.assertEqual(dns_notification_message((result,)), b"")
def test_private_key_import_refuses_symlinks_and_writable_keys(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
key = root / "dkim.key"
key.write_text("private")
key.chmod(0o620)
with self.assertRaisesRegex(ConfigurationError, "untrusted users"):
_private_key_data(key)
key.chmod(0o600)
link = root / "dkim-link.key"
link.symlink_to(key)
with self.assertRaisesRegex(ConfigurationError,
"not a regular file"):
_private_key_data(link)
def test_private_key_import_requires_rsa_2048_or_stronger(self):
with tempfile.TemporaryDirectory() as temporary:
key = Path(temporary) / "dkim.key"
key.write_text("private")
key.chmod(0o600)
completed = SimpleNamespace(
stdout=b"Public Key Algorithm: EC\nMedium (256 bits)\n")
with mock.patch(
"bongo.configuration.security_material.shutil.which",
return_value="/usr/bin/certtool"), mock.patch(
"bongo.configuration.security_material._run",
return_value=completed):
with self.assertRaisesRegex(ConfigurationError,
"RSA key of at least"):
_private_key_data(key)
def test_provision_writes_private_key_dns_hint_and_dmarc_state(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@@ -126,6 +170,77 @@ class MailSecurityMaterialTest(unittest.TestCase):
self.assertEqual(repeated[0].actions, results[0].actions)
generate.assert_called_once_with("example.test", "bongo")
def test_matching_published_records_need_no_action(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
paths = SimpleNamespace(
ssl_dir=root / "ssl.d", dkim_dir=root / "dkim.d",
dmarc_dir=root / "dmarc.d")
scenario = Scenario(
hostname="mail.example.test", bind_address="192.0.2.5",
domains=["example.test"], admin_password="0123456789ab",
dkim_selector="bongo",
dkim_key_directory=str(paths.dkim_dir),
mail_dns={"example.test": {
"spf": ["v=spf1 mx -all"],
"dkim": ["v=DKIM1; k=rsa; p=PUBLIC"],
"dmarc": ["v=DMARC1; p=reject; "
"rua=mailto:dmarc-reports@example.test"],
"errors": [],
}},
)
with mock.patch(
"bongo.configuration.security_material.os.geteuid",
return_value=0), mock.patch(
"bongo.configuration.security_material.os.chown"), mock.patch(
"bongo.configuration.security_material.os.fchown"), \
mock.patch(
"bongo.configuration.security_material._bongo_ids",
return_value=(os.getuid(), os.getgid())), mock.patch(
"bongo.configuration.security_material._generate_private_key",
return_value=(b"PRIVATE KEY\n", "PUBLIC")):
results = provision_security_material(paths, scenario)
self.assertEqual(results[0].actions, ())
self.assertEqual(dns_notification_message(results), b"")
def test_dns_task_is_actionable_idempotent_and_private(self):
result = DomainSecurityResult(
"example.test", "bongo", {},
{"spf": "v=spf1 mx -all",
"dkim": "v=DKIM1; k=rsa; p=PUBLIC",
"dmarc": "v=DMARC1; p=none"},
({"action": "publish", "name": "example.test",
"type": "TXT", "value": "v=spf1 mx -all"},))
with tempfile.TemporaryDirectory() as temporary:
database = Path(temporary) / "tasks" / "tasks.sqlite3"
configurations = {"web": {"task_database": str(database)}}
with mock.patch(
"bongo.configuration.security_material._bongo_ids",
return_value=(os.getuid(), os.getgid())):
install_dns_tasks(configurations, (result,))
install_dns_tasks(configurations, (result,))
from bongo_web.tasks import TaskStore
tasks = TaskStore(database).list("admin")
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0]["type"], "mail-dns")
self.assertEqual(tasks[0]["key"], "example.test")
self.assertEqual(tasks[0]["severity"], "warning")
self.assertTrue(tasks[0]["dismissible"])
self.assertEqual(tasks[0]["details"]["actions"][0]["value"],
"v=spf1 mx -all")
self.assertEqual(database.stat().st_mode & 0o777, 0o600)
self.assertEqual(database.parent.stat().st_mode & 0o777, 0o750)
def test_no_dns_task_database_is_created_without_actions(self):
result = DomainSecurityResult(
"example.test", "bongo", {},
{"spf": "", "dkim": "", "dmarc": ""}, ())
with tempfile.TemporaryDirectory() as temporary:
database = Path(temporary) / "tasks" / "tasks.sqlite3"
install_dns_tasks({"web": {"task_database": str(database)}},
(result,))
self.assertFalse(database.exists())
if __name__ == "__main__":
unittest.main()