Merge upstream v0.16.25

Brings in the stripped v0.16.25 snapshot (72f8ddd), a bug-fix release:
DKIM rotation (keys retired before their successor is published, keys
made under manual DNS never rotating, manual DNS activating an
unpublished key), IMAP answering failed logins with an untagged NO,
DNSBL scoring only the first return code and caching "not listed" for
24 hours, Pyzor digesting empty input, queue quotas with an empty match
never enforced, RocksDB's info log growing without limit, MaskedEmail
creation with several domains, and Autodiscover answering other schemas
with the Outlook settings.

Conflicts:
- crates/main/Cargo.toml: inbuxa's name and AGPL-only license, version
  0.16.25.
- SECURITY.md and .github/PULL_REQUEST_TEMPLATE.md: inbuxa's own.
- Cargo.lock: upstream's, re-resolved against inbuxa's manifests.
- crates/common/src/network/autoconfig/autodiscover.rs: upstream now
  parses the request into a struct, so the legacy-protocol switch
  (LP-7) reads the address from request.email; ActiveSync and other
  schemas get upstream's error 601 untouched.
- resources/schema: unchanged upstream apart from two labels the rename
  pass now covers, which main already had.

AGENTS.md, new upstream, is left out: it is about contributing to
upstream, which doesn't apply to this repository.
This commit is contained in:
jcoffey-dev committed 2026-10-05 21:20:26 -07:00
commit db4135e481
60 files changed
+1574 -488

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tests"
version = "0.16.24"
version = "0.16.25"
edition = "2024"
[features]
+2 -2
View File
@@ -6,8 +6,8 @@ spf.result none
spf_ehlo.result none
dmarc.result none
remote_ip 195.210.29.48
expect_header X-Spam-Result: ARC_NA (0.00), DKIM2_NA (0.00), DKIM_NA (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_DATA_URI (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), SPF_NA (0.00), SUBJECT_ENDS_EXCLAIM (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), MIME_HTML_ONLY (0.20), HELO_NORES_A_OR_MX (0.30), AUTH_NA (1.00), DATE_IN_PAST (1.00), DMARC_NA (1.00), MID_RHS_MATCH_FROM (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00), PYZOR (3.50)
expect_header X-Spam-Score: spam, score=13.70
expect_header X-Spam-Result: ARC_NA (0.00), DKIM2_NA (0.00), DKIM_NA (0.00), FROM_EQ_ENV_FROM (0.00), FROM_HAS_DN (0.00), HAS_DATA_URI (0.00), HAS_LINK_TO_LARGE_IMG (0.00), HTML_SHORT_1 (0.00), MID_RHS_MATCH_ENV_FROM (0.00), RCPT_COUNT_ONE (0.00), SPF_NA (0.00), SUBJECT_ENDS_EXCLAIM (0.00), TO_DN_NONE (0.00), TO_MATCH_ENVRCPT_ALL (0.00), RCVD_COUNT_ZERO (0.10), RCVD_NO_TLS_LAST (0.10), MIME_HTML_ONLY (0.20), HELO_NORES_A_OR_MX (0.30), AUTH_NA (1.00), DATE_IN_PAST (1.00), DMARC_NA (1.00), MID_RHS_MATCH_FROM (1.00), FROMHOST_NORES_A_OR_MX (1.50), HTML_SHORT_LINK_IMG_1 (2.00), RDNS_NONE (2.00)
expect_header X-Spam-Score: spam, score=10.20
From: Client Services <[email protected]>
To: [email protected]
+8
View File
@@ -38,6 +38,14 @@ My e-mail is [email protected]
And my website is https://sem-fresh15.com/offers.html
Try cheating with a trusted domain [email protected]
<!-- NEXT TEST -->
expect DBL_SPAM DBL_PHISH
From: [email protected]
Subject: test
Our website is https://dbl-multi.com/offers.html
<!-- NEXT TEST -->
expect DBL_MALWARE
+662 -59
View File
@@ -10,20 +10,37 @@ use common::{config::smtp::auth::Dkim1Signer, network::dns::update::DNS_RECORDS}
use dns_update::{DnsRecord, NamedDnsRecord};
use registry::{
schema::{
enums::{DkimRotationStage, DnsRecordType},
prelude::ObjectType,
enums::{DkimRotationStage, DnsRecordType, IpProtocol, TsigAlgorithm},
prelude::{ObjectType, Property},
structs::{
CertificateManagement, Dkim1Signature, DkimManagement, DkimManagementProperties,
DkimSignature, DnsManagement, DnsManagementProperties, DnsServer, DnsServerCloudflare,
Domain, SecretKey, SecretKeyValue,
DnsServerTsig, Domain, SecretKey, SecretKeyValue, Task, TaskDomainManagement,
TaskManager, TaskRetryStrategy, TaskRetryStrategyFixed, TaskStatus,
},
},
types::duration::Duration,
types::{duration::Duration, map::Map},
};
use serde_json::json;
use store::write::now;
use types::id::Id;
const SHORT_ROTATION_MS: u64 = 6_000;
const LONG_ROTATION_MS: u64 = 3_600_000;
pub async fn test(test: &TestServer) {
fast_retry_tests(test).await;
unscheduled_keys_test(test, "dkim-manual.org", |_| DnsManagement::Manual).await;
unscheduled_keys_test(test, "dkim-unpublished.org", |dns_server_id| {
DnsManagement::Automatic(DnsManagementProperties {
dns_server_id,
publish_records: Map::new(vec![DnsRecordType::Spf]),
..Default::default()
})
})
.await;
automatic_to_manual_dns_test(test).await;
println!("Running DKIM Management tests...");
let account = test.account("[email protected]");
DNS_RECORDS.lock().unwrap().clear();
@@ -98,8 +115,8 @@ pub async fn test(test: &TestServer) {
// Make sure the DNS records were created
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
// Expect a rotation to happen and new keys to be created
let rot2_signatures = account
@@ -111,10 +128,10 @@ pub async fn test(test: &TestServer) {
// Make sure both old and new keys have DNS records
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, &rot1_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
// Make sure only the new keys are being used for signing
assert_ne!(
@@ -145,19 +162,19 @@ pub async fn test(test: &TestServer) {
// Make sure the old records were deleted
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_no_dns_record(&records, &rot1_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&records, &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, &rot2_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, &rot3_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot3_signatures.v1_ed25519[0]);
assert_key_has_no_dns_record(&records, "dkim.org", &rot1_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&records, "dkim.org", &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot3_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot3_signatures.v1_ed25519[0]);
// Make sure the DNS management task does not republish the retired keys
let (published, zone_file) = test.published_dkim_records(domain_id).await;
assert_key_has_no_dns_record(&published, &rot1_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&published, &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&published, &rot3_signatures.v1_rsa[0]);
assert_key_has_dns_record(&published, &rot3_signatures.v1_ed25519[0]);
assert_key_has_no_dns_record(&published, "dkim.org", &rot1_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&published, "dkim.org", &rot1_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&published, "dkim.org", &rot3_signatures.v1_rsa[0]);
assert_key_has_dns_record(&published, "dkim.org", &rot3_signatures.v1_ed25519[0]);
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_rsa[0]);
assert_zone_file_omits_key(&zone_file, &rot1_signatures.v1_ed25519[0]);
@@ -192,17 +209,17 @@ pub async fn test(test: &TestServer) {
// Make sure the old records were updated
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, &rot4_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, &rot4_signatures.v1_ed25519[0]);
assert_key_has_no_dns_record(&records, &rot2_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&records, &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot4_signatures.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim.org", &rot4_signatures.v1_ed25519[0]);
assert_key_has_no_dns_record(&records, "dkim.org", &rot2_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&records, "dkim.org", &rot2_signatures.v1_ed25519[0]);
// Make sure the DNS management task does not republish the retired keys
let (published, zone_file) = test.published_dkim_records(domain_id).await;
assert_key_has_no_dns_record(&published, &rot2_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&published, &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&published, &rot4_signatures.v1_rsa[0]);
assert_key_has_dns_record(&published, &rot4_signatures.v1_ed25519[0]);
assert_key_has_no_dns_record(&published, "dkim.org", &rot2_signatures.v1_rsa[0]);
assert_key_has_no_dns_record(&published, "dkim.org", &rot2_signatures.v1_ed25519[0]);
assert_key_has_dns_record(&published, "dkim.org", &rot4_signatures.v1_rsa[0]);
assert_key_has_dns_record(&published, "dkim.org", &rot4_signatures.v1_ed25519[0]);
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_rsa[0]);
assert_zone_file_omits_key(&zone_file, &rot2_signatures.v1_ed25519[0]);
@@ -235,6 +252,470 @@ pub async fn test(test: &TestServer) {
account.registry_destroy_all(ObjectType::DnsServer).await;
}
async fn fast_retry_tests(test: &TestServer) {
let account = test.account("[email protected]");
// Retry failed tasks every second
account
.registry_update_setting(
TaskManager {
max_attempts: 100,
strategy: TaskRetryStrategy::FixedDelay(TaskRetryStrategyFixed {
delay: 1_000u64.into(),
}),
total_deadline: 86_400_000u64.into(),
},
&[],
)
.await;
account.reload_settings().await;
failed_publish_test(test).await;
manual_dns_pending_test(test).await;
account
.registry_update_setting(TaskManager::default(), &[])
.await;
account.reload_settings().await;
}
async fn failed_publish_test(test: &TestServer) {
println!("Running DKIM failed publish tests...");
let account = test.account("[email protected]");
DNS_RECORDS.lock().unwrap().clear();
account.dkim_signatures().await.assert_total(0, 0);
// Create an in-memory DNS server and a DNS server that refuses connections
let dns_server_id = account.create_memory_dns_server().await;
let failing_dns_server_id = account.create_failing_dns_server().await;
// Create a domain whose initial keys rotate shortly
let domain_id = account
.registry_create_object(Domain {
name: "dkim-retry.org".to_string(),
certificate_management: CertificateManagement::Manual,
dkim_management: dkim_management(SHORT_ROTATION_MS),
dns_management: dns_management(dns_server_id),
..Default::default()
})
.await;
let initial = account
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
.await
.assert_total(1, 1);
let old_rsa = initial.v1_rsa[0].selector.clone();
let old_ed = initial.v1_ed25519[0].selector.clone();
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
.await;
// Point the domain at the failing DNS server before the rotation is due, and
// make the keys created from now on long-lived
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: dns_management(failing_dns_server_id),
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
}),
)
.await;
assert!(
initial.v1_rsa[0].next_transition_at.unwrap().timestamp() > now() as i64,
"Rotation was due before the DNS server could be replaced: {:#?}",
initial
);
// The new keys cannot be published, so they must stay pending while the
// old keys remain active and keep signing
let failed = account
.wait_for_dkim_stages(&[
(DkimRotationStage::Pending, 2),
(DkimRotationStage::Active, 2),
])
.await
.assert_total(2, 2)
.assert_selector_stage(&old_rsa, DkimRotationStage::Active)
.assert_selector_stage(&old_ed, DkimRotationStage::Active);
let new_rsa = failed.v1_rsa[0].selector.clone();
let new_ed = failed.v1_ed25519[0].selector.clone();
let failed = failed
.assert_selector_stage(&new_rsa, DkimRotationStage::Pending)
.assert_selector_stage(&new_ed, DkimRotationStage::Pending);
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
.await;
// Several retries must neither create duplicate keys nor retire the old ones
let failure_reason = account.wait_for_dkim_task_attempts(domain_id, 4).await;
assert!(
failure_reason.contains("Failed to publish DKIM record"),
"Unexpected failure reason: {failure_reason}"
);
let retried = account.dkim_signatures().await;
assert_eq!(
retried, failed,
"DKIM signatures changed while the DNS server was failing"
);
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
.await;
// Under manual DNS management the pending keys stay pending next to the
// active keys, and the task stops retrying
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: DnsManagement::Manual,
}),
)
.await;
account.wait_for_no_dkim_tasks(domain_id).await;
assert_eq!(
account.dkim_signatures().await,
failed,
"DKIM signatures changed under manual DNS management"
);
test.assert_has_signers("dkim-retry.org", &[&old_rsa, &old_ed])
.await;
// Once automatic DNS management uses a working server again, the pending
// keys are activated and the old keys start retiring
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: dns_management(dns_server_id),
}),
)
.await;
let recovered = account
.wait_for_dkim_stages(&[
(DkimRotationStage::Active, 2),
(DkimRotationStage::Retiring, 2),
])
.await
.assert_total(2, 2)
.assert_selector_stage(&new_rsa, DkimRotationStage::Active)
.assert_selector_stage(&new_ed, DkimRotationStage::Active)
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
test.assert_has_signers("dkim-retry.org", &[&new_rsa, &new_ed])
.await;
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, "dkim-retry.org", &recovered.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim-retry.org", &recovered.v1_ed25519[0]);
// Cleanup
account.registry_destroy_all(ObjectType::Task).await;
account
.registry_destroy_all(ObjectType::DkimSignature)
.await;
account
.registry_destroy(ObjectType::Domain, [domain_id])
.await
.assert_destroyed(&[domain_id]);
account.registry_destroy_all(ObjectType::DnsServer).await;
}
async fn unscheduled_keys_test(
test: &TestServer,
domain: &str,
initial_dns_management: fn(Id) -> DnsManagement,
) {
println!("Running DKIM unscheduled key tests for {domain}...");
let account = test.account("[email protected]");
DNS_RECORDS.lock().unwrap().clear();
account.dkim_signatures().await.assert_total(0, 0);
let dns_server_id = account.create_memory_dns_server().await;
// Keys created while DKIM records are not published automatically are
// active, unpublished and have no rotation schedule
let domain_id = account
.registry_create_object(Domain {
name: domain.to_string(),
certificate_management: CertificateManagement::Manual,
dkim_management: dkim_management(SHORT_ROTATION_MS),
dns_management: initial_dns_management(dns_server_id),
..Default::default()
})
.await;
let initial = account
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
.await
.assert_total(1, 1);
assert!(
initial.keys().all(|key| key.next_transition_at.is_none()),
"Unexpected rotation schedule for unpublished keys: {initial:#?}"
);
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_no_dns_record(&records, domain, &initial.v1_rsa[0]);
assert_key_has_no_dns_record(&records, domain, &initial.v1_ed25519[0]);
let old_rsa = initial.v1_rsa[0].selector.clone();
let old_ed = initial.v1_ed25519[0].selector.clone();
// Publishing DKIM records automatically publishes the keys and schedules
// their rotation
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: dns_management(dns_server_id),
}),
)
.await;
let scheduled = account
.wait_for_dkim("active keys with a rotation schedule", |signatures| {
signatures.total() == 2
&& signatures.stage_count(DkimRotationStage::Active) == 2
&& signatures
.keys()
.all(|key| key.next_transition_at.is_some())
})
.await;
// Make the keys created by the rotation long-lived
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
}),
)
.await;
assert!(
scheduled
.keys()
.all(|key| key.next_transition_at.unwrap().timestamp() > now() as i64),
"Rotation was due before the rotation period could be extended: {scheduled:#?}"
);
wait_for_dns_records(domain, &[&old_rsa, &old_ed]).await;
// The keys rotate once the schedule elapses
let rotated = account
.wait_for_dkim_stages(&[
(DkimRotationStage::Active, 2),
(DkimRotationStage::Retiring, 2),
])
.await
.assert_total(2, 2)
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
test.assert_has_signers(
domain,
&[&rotated.v1_rsa[0].selector, &rotated.v1_ed25519[0].selector],
)
.await;
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, domain, &rotated.v1_rsa[0]);
assert_key_has_dns_record(&records, domain, &rotated.v1_ed25519[0]);
// Cleanup
account.registry_destroy_all(ObjectType::Task).await;
account
.registry_destroy_all(ObjectType::DkimSignature)
.await;
account
.registry_destroy(ObjectType::Domain, [domain_id])
.await
.assert_destroyed(&[domain_id]);
account.registry_destroy_all(ObjectType::DnsServer).await;
}
async fn automatic_to_manual_dns_test(test: &TestServer) {
println!("Running DKIM automatic to manual DNS tests...");
let account = test.account("[email protected]");
DNS_RECORDS.lock().unwrap().clear();
account.dkim_signatures().await.assert_total(0, 0);
let dns_server_id = account.create_memory_dns_server().await;
// Create a domain whose initial keys rotate shortly
let domain_id = account
.registry_create_object(Domain {
name: "dkim-mirror.org".to_string(),
certificate_management: CertificateManagement::Manual,
dkim_management: dkim_management(SHORT_ROTATION_MS),
dns_management: dns_management(dns_server_id),
..Default::default()
})
.await;
let initial = account
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
.await
.assert_total(1, 1);
let old_rsa = initial.v1_rsa[0].selector.clone();
let old_ed = initial.v1_ed25519[0].selector.clone();
// Switch to manual DNS management before the rotation is due, and make the
// keys created from now on long-lived
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: DnsManagement::Manual,
Property::DkimManagement: dkim_management(LONG_ROTATION_MS),
}),
)
.await;
let due = initial.v1_rsa[0].next_transition_at.unwrap().timestamp();
let wait_secs = due - now() as i64;
assert!(
wait_secs > 0,
"Rotation was due before DNS management was switched: {initial:#?}"
);
// Once the rotation is due, the task must neither rotate the keys nor keep
// retrying
tokio::time::sleep(std::time::Duration::from_secs(wait_secs as u64 + 1)).await;
account.wait_for_no_dkim_tasks(domain_id).await;
assert_eq!(
account.dkim_signatures().await,
initial,
"DKIM signatures changed under manual DNS management"
);
test.assert_has_signers("dkim-mirror.org", &[&old_rsa, &old_ed])
.await;
// Switching back to automatic DNS management completes the overdue rotation
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: dns_management(dns_server_id),
}),
)
.await;
let rotated = account
.wait_for_dkim_stages(&[
(DkimRotationStage::Active, 2),
(DkimRotationStage::Retiring, 2),
])
.await
.assert_total(2, 2)
.assert_selector_stage(&old_rsa, DkimRotationStage::Retiring)
.assert_selector_stage(&old_ed, DkimRotationStage::Retiring);
test.assert_has_signers(
"dkim-mirror.org",
&[&rotated.v1_rsa[0].selector, &rotated.v1_ed25519[0].selector],
)
.await;
let records = DNS_RECORDS.lock().unwrap().clone();
assert_key_has_dns_record(&records, "dkim-mirror.org", &rotated.v1_rsa[0]);
assert_key_has_dns_record(&records, "dkim-mirror.org", &rotated.v1_ed25519[0]);
// Cleanup
account.registry_destroy_all(ObjectType::Task).await;
account
.registry_destroy_all(ObjectType::DkimSignature)
.await;
account
.registry_destroy(ObjectType::Domain, [domain_id])
.await
.assert_destroyed(&[domain_id]);
account.registry_destroy_all(ObjectType::DnsServer).await;
}
async fn manual_dns_pending_test(test: &TestServer) {
println!("Running DKIM pending key under manual DNS tests...");
let account = test.account("[email protected]");
DNS_RECORDS.lock().unwrap().clear();
account.dkim_signatures().await.assert_total(0, 0);
let failing_dns_server_id = account.create_failing_dns_server().await;
// Keys created while the DNS server is failing stay pending
let domain_id = account
.registry_create_object(Domain {
name: "dkim-pending.org".to_string(),
certificate_management: CertificateManagement::Manual,
dkim_management: dkim_management(LONG_ROTATION_MS),
dns_management: dns_management(failing_dns_server_id),
..Default::default()
})
.await;
let pending = account
.wait_for_dkim_stages(&[(DkimRotationStage::Pending, 2)])
.await
.assert_total(1, 1);
let rsa = pending.v1_rsa[0].selector.clone();
let ed = pending.v1_ed25519[0].selector.clone();
// Switching to manual DNS management activates the pending keys without a
// rotation schedule, and the task stops retrying
account
.registry_update_object(
ObjectType::Domain,
domain_id,
json!({
Property::DnsManagement: DnsManagement::Manual,
}),
)
.await;
let active = account
.wait_for_dkim_stages(&[(DkimRotationStage::Active, 2)])
.await
.assert_total(1, 1)
.assert_selector_stage(&rsa, DkimRotationStage::Active)
.assert_selector_stage(&ed, DkimRotationStage::Active);
assert!(
active.keys().all(|key| key.next_transition_at.is_none()),
"Unexpected rotation schedule under manual DNS management: {active:#?}"
);
test.assert_has_signers("dkim-pending.org", &[&rsa, &ed])
.await;
account.wait_for_no_dkim_tasks(domain_id).await;
// Cleanup
account.registry_destroy_all(ObjectType::Task).await;
account
.registry_destroy_all(ObjectType::DkimSignature)
.await;
account
.registry_destroy(ObjectType::Domain, [domain_id])
.await
.assert_destroyed(&[domain_id]);
account.registry_destroy_all(ObjectType::DnsServer).await;
}
fn dns_management(dns_server_id: Id) -> DnsManagement {
DnsManagement::Automatic(DnsManagementProperties {
dns_server_id,
publish_records: Map::new(vec![DnsRecordType::Dkim]),
..Default::default()
})
}
fn dkim_management(rotate_after: u64) -> DkimManagement {
DkimManagement::Automatic(DkimManagementProperties {
delete_after: Duration::from_millis(LONG_ROTATION_MS),
retire_after: Duration::from_millis(LONG_ROTATION_MS),
rotate_after: Duration::from_millis(rotate_after),
selector_template: "dummy-v{version}-{algorithm}-{epoch}".to_string(),
..Default::default()
})
}
async fn wait_for_dns_records(domain: &str, selectors: &[&str]) {
for _ in 0..50 {
let records = DNS_RECORDS.lock().unwrap().clone();
if selectors
.iter()
.all(|selector| has_dns_record(&records, domain, selector))
{
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
panic!(
"DNS records for selectors {selectors:?} were not published: {:#?}",
DNS_RECORDS.lock().unwrap()
);
}
#[derive(Debug, PartialEq, Eq, Default)]
struct DkimSignatures {
v1_rsa: Vec<Dkim1Signature>,
@@ -265,6 +746,108 @@ impl Account {
);
}
async fn wait_for_dkim_stages(
&self,
expected: &[(DkimRotationStage, usize)],
) -> DkimSignatures {
let expected_total = expected.iter().map(|(_, count)| count).sum::<usize>();
self.wait_for_dkim(&format!("stages {expected:?}"), |signatures| {
signatures.total() == expected_total
&& expected
.iter()
.all(|(stage, count)| signatures.stage_count(*stage) == *count)
})
.await
}
async fn wait_for_dkim(
&self,
expected: &str,
is_expected: impl Fn(&DkimSignatures) -> bool,
) -> DkimSignatures {
let mut signatures = self.dkim_signatures().await;
for _ in 0..50 {
if is_expected(&signatures) {
return signatures;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
signatures = self.dkim_signatures().await;
}
panic!("DKIM signatures did not reach {expected}: {signatures:#?}");
}
async fn wait_for_no_dkim_tasks(&self, domain_id: Id) {
for _ in 0..60 {
if self.tasks().await.is_some_and(|tasks| {
!tasks.iter().any(|task| {
matches!(&task.task, Task::DkimManagement(task) if task.domain_id == domain_id)
})
}) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
panic!(
"DKIM management task did not complete: {:#?}",
self.tasks()
.await
.map(|tasks| tasks.into_iter().map(|task| task.task).collect::<Vec<_>>())
);
}
async fn create_failing_dns_server(&self) -> Id {
self.registry_create_object(DnsServer::Tsig(DnsServerTsig {
host: "127.0.0.1".parse().unwrap(),
port: 1,
key_name: "stalwart-update-key".to_string(),
key: SecretKey::Value(SecretKeyValue {
secret: "c3RhbHdhcnQtdGVzdC10c2lnLXNlY3JldA==".into(),
}),
protocol: IpProtocol::Tcp,
tsig_algorithm: TsigAlgorithm::HmacSha256,
description: "Unreachable DNS server".to_string(),
timeout: Duration::from_millis(1_000),
..Default::default()
}))
.await
}
async fn create_memory_dns_server(&self) -> Id {
self.registry_create_object(DnsServer::Cloudflare(DnsServerCloudflare {
secret: SecretKey::Value(SecretKeyValue {
secret: "[email protected]".into(),
}),
description: "In-memory DNS server".to_string(),
..Default::default()
}))
.await
}
async fn wait_for_dkim_task_attempts(&self, domain_id: Id, attempts: u64) -> String {
for _ in 0..60 {
if let Some(tasks) = self.tasks().await
&& let Some(failure_reason) = tasks.into_iter().find_map(|task| match task.task {
Task::DkimManagement(TaskDomainManagement {
domain_id: task_domain_id,
status: TaskStatus::Retry(retry),
}) if task_domain_id == domain_id && retry.attempt_number >= attempts => {
Some(retry.failure_reason)
}
_ => None,
})
{
return failure_reason;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
panic!(
"DKIM management task did not reach {attempts} attempts: {:#?}",
self.tasks()
.await
.map(|tasks| tasks.into_iter().map(|task| task.task).collect::<Vec<_>>())
);
}
async fn dkim_signatures(&self) -> DkimSignatures {
let signatures = self.registry_get_all::<DkimSignature>().await;
let mut v1_rsa = Vec::new();
@@ -286,9 +869,35 @@ impl Account {
}
impl DkimSignatures {
fn keys(&self) -> impl Iterator<Item = &Dkim1Signature> {
self.v1_rsa.iter().chain(&self.v1_ed25519)
}
fn total(&self) -> usize {
self.v1_rsa.len() + self.v1_ed25519.len()
}
fn stage_count(&self, stage: DkimRotationStage) -> usize {
self.v1_rsa.iter().filter(|s| s.stage == stage).count()
+ self.v1_ed25519.iter().filter(|s| s.stage == stage).count()
}
fn assert_selector_stage(self, selector: &str, stage: DkimRotationStage) -> Self {
assert!(
self.v1_rsa
.iter()
.chain(self.v1_ed25519.iter())
.any(|s| s.selector == selector && s.stage == stage),
"Expected selector {} in stage {:?}: {:#?}",
selector,
stage,
self
);
self
}
fn assert_stage_count(self, stage: DkimRotationStage, count: usize) -> Self {
let actual_count = self.v1_rsa.iter().filter(|s| s.stage == stage).count()
+ self.v1_ed25519.iter().filter(|s| s.stage == stage).count();
let actual_count = self.stage_count(stage);
assert_eq!(
actual_count, count,
"Expected {} signatures in stage {:?}, found {}: {:#?}",
@@ -369,21 +978,23 @@ impl TestServer {
}
}
fn assert_key_has_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) {
let expected = format!("{}._domainkey.dkim.org.", key.selector);
for record in records {
if record.name == expected
&& let DnsRecord::TXT(txt) = &record.record
&& ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|| (key.selector.contains("ed25519")
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
{
return;
}
}
panic!(
fn has_dns_record(records: &[NamedDnsRecord], domain: &str, selector: &str) -> bool {
let expected = format!("{selector}._domainkey.{domain}.");
records.iter().any(|record| {
record.name == expected
&& matches!(&record.record, DnsRecord::TXT(txt)
if (selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|| (selector.contains("ed25519")
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
})
}
fn assert_key_has_dns_record(records: &[NamedDnsRecord], domain: &str, key: &Dkim1Signature) {
assert!(
has_dns_record(records, domain, &key.selector),
"No DNS record found for DKIM key with selector {}, records: {:#?}",
key.selector, records
key.selector,
records
);
}
@@ -396,19 +1007,11 @@ fn assert_zone_file_omits_key(zone_file: &str, key: &Dkim1Signature) {
);
}
fn assert_key_has_no_dns_record(records: &[NamedDnsRecord], key: &Dkim1Signature) {
let expected = format!("{}._domainkey.dkim.org.", key.selector);
for record in records {
if record.name == expected
&& let DnsRecord::TXT(txt) = &record.record
&& ((key.selector.contains("rsa") && txt.starts_with("v=DKIM1; k=rsa; h=sha256; p="))
|| (key.selector.contains("ed25519")
&& txt.starts_with("v=DKIM1; k=ed25519; h=sha256; p=")))
{
panic!(
"Unexpected DNS record found for DKIM key with selector {}, records: {:#?}",
key.selector, records
);
}
}
fn assert_key_has_no_dns_record(records: &[NamedDnsRecord], domain: &str, key: &Dkim1Signature) {
assert!(
!has_dns_record(records, domain, &key.selector),
"Unexpected DNS record found for DKIM key with selector {}, records: {:#?}",
key.selector,
records
);
}
+6
View File
@@ -211,6 +211,12 @@ async fn antispam() {
Instant::now() + Duration::from_secs(100),
);
}
// A DNSBL answer with several codes must produce one tag per code
test.server.dnsbl_add(
"dbl-multi.com.dbl.spamhaus.org",
vec!["127.0.1.2".parse().unwrap(), "127.0.1.4".parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
for mx in [
"domain.org",
"domain.co.uk",
+8
View File
@@ -209,6 +209,14 @@ pub async fn test(test: &mut TestServer) {
assert_eq!(samples.iter().filter(|x| x.1.is_spam).count(), 11);
assert_eq!(samples.len(), 20);
// Removing the duplicate sample of a reclassified email should not remove the blob of the kept sample
for (id, sample) in &samples {
assert!(
client.download(&sample.blob_id.to_string()).await.is_ok(),
"blob of sample {id} is not accessible"
);
}
// Adding a training sample without permissions should fail
assert_eq!(
account
+5 -7
View File
@@ -77,14 +77,12 @@ impl DnsCache for Server {
fn dnsbl_add(&self, name: &str, value: Vec<Ipv4Addr>, valid_until: std::time::Instant) {
self.inner.cache.dns_rbl.insert_with_expiry(
name.into(),
Some(Arc::new(IpResolver::new(
Some(
value
.iter()
.copied()
.next()
.unwrap_or(Ipv4Addr::BROADCAST)
.into(),
))),
.into_iter()
.map(|ip| IpResolver::new(ip.into()))
.collect(),
),
valid_until,
);
}