Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db817dd507 | ||
|
|
b7e3a765ca | ||
|
|
7ba9ec9fa0 | ||
|
|
4c07779c16 | ||
|
|
e1e8a9aeb0 | ||
|
|
b1bc5ed6e0 |
@@ -9,9 +9,6 @@
|
|||||||
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
//! calendars, contacts and files, and the deleted items the hold keeps, each
|
||||||
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
//! with its SHA-256 in `manifest.csv`, and the manifest's own hash beside
|
||||||
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
//! it. The hold's date range applies as it does to what's kept (LH-3).
|
||||||
//! Anything the hold covers that can't be read goes in `exceptions.csv`
|
|
||||||
//! with the reason, never silently left out; the file is always there, so an
|
|
||||||
//! empty one says nothing was missed.
|
|
||||||
|
|
||||||
use common::{Server, hold::kept_member};
|
use common::{Server, hold::kept_member};
|
||||||
use email::{
|
use email::{
|
||||||
@@ -55,21 +52,6 @@ struct Entry {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line of `exceptions.csv`: an item the hold covers that couldn't be
|
|
||||||
/// read, where it would have gone and why.
|
|
||||||
struct Missing {
|
|
||||||
path: String,
|
|
||||||
account: String,
|
|
||||||
kind: &'static str,
|
|
||||||
folder: String,
|
|
||||||
date: Option<i64>,
|
|
||||||
archived: bool,
|
|
||||||
reason: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
const NO_BLOB: &str = "content not found in the blob store";
|
|
||||||
const NO_RECORD: &str = "stored record not found";
|
|
||||||
|
|
||||||
fn hex(bytes: &[u8]) -> String {
|
fn hex(bytes: &[u8]) -> String {
|
||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
@@ -100,7 +82,6 @@ fn date_text(at: Option<i64>) -> String {
|
|||||||
struct Builder {
|
struct Builder {
|
||||||
zip: ZipWriter<Cursor<Vec<u8>>>,
|
zip: ZipWriter<Cursor<Vec<u8>>>,
|
||||||
entries: Vec<Entry>,
|
entries: Vec<Entry>,
|
||||||
missing: Vec<Missing>,
|
|
||||||
written: u64,
|
written: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +90,6 @@ impl Builder {
|
|||||||
Builder {
|
Builder {
|
||||||
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
zip: ZipWriter::new(Cursor::new(Vec::new())),
|
||||||
entries: Vec::new(),
|
entries: Vec::new(),
|
||||||
missing: Vec::new(),
|
|
||||||
written: 0,
|
written: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,31 +144,8 @@ impl Builder {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records an item the hold covers that couldn't be read.
|
/// Closes the ZIP with its manifest and the manifest's hash. Returns the
|
||||||
#[allow(clippy::too_many_arguments)]
|
/// bytes and how many items went in.
|
||||||
fn missing(
|
|
||||||
&mut self,
|
|
||||||
path: String,
|
|
||||||
account: &str,
|
|
||||||
kind: &'static str,
|
|
||||||
folder: &str,
|
|
||||||
date: Option<i64>,
|
|
||||||
archived: bool,
|
|
||||||
reason: &'static str,
|
|
||||||
) {
|
|
||||||
self.missing.push(Missing {
|
|
||||||
path,
|
|
||||||
account: account.to_string(),
|
|
||||||
kind,
|
|
||||||
folder: folder.to_string(),
|
|
||||||
date,
|
|
||||||
archived,
|
|
||||||
reason,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closes the ZIP with its manifest, the exceptions and both hashes.
|
|
||||||
/// Returns the bytes and how many items went in.
|
|
||||||
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
fn finish(mut self) -> trc::Result<(Vec<u8>, usize)> {
|
||||||
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
let mut manifest = String::from("path,account,kind,folder,date,archived,size,sha256\n");
|
||||||
for e in &self.entries {
|
for e in &self.entries {
|
||||||
@@ -204,21 +161,7 @@ impl Builder {
|
|||||||
e.sha256
|
e.sha256
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let mut exceptions = String::from("path,account,kind,folder,date,archived,reason\n");
|
|
||||||
for m in &self.missing {
|
|
||||||
exceptions.push_str(&format!(
|
|
||||||
"{},{},{},{},{},{},{}\n",
|
|
||||||
csv(&m.path),
|
|
||||||
csv(&m.account),
|
|
||||||
m.kind,
|
|
||||||
csv(&m.folder),
|
|
||||||
date_text(m.date),
|
|
||||||
m.archived,
|
|
||||||
csv(m.reason)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||||
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
|
|
||||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||||
let fail = |err: zip::result::ZipError| {
|
let fail = |err: zip::result::ZipError| {
|
||||||
trc::StoreEvent::UnexpectedError
|
trc::StoreEvent::UnexpectedError
|
||||||
@@ -228,11 +171,9 @@ impl Builder {
|
|||||||
};
|
};
|
||||||
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
self.zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||||
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
self.zip.write_all(manifest.as_bytes()).map_err(|e| fail(e.into()))?;
|
||||||
self.zip.start_file("exceptions.csv", options).map_err(fail)?;
|
|
||||||
self.zip.write_all(exceptions.as_bytes()).map_err(|e| fail(e.into()))?;
|
|
||||||
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
self.zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||||
self.zip
|
self.zip
|
||||||
.write_all(format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes())
|
.write_all(format!("{manifest_hash} manifest.csv\n").as_bytes())
|
||||||
.map_err(|e| fail(e.into()))?;
|
.map_err(|e| fail(e.into()))?;
|
||||||
let items = self.entries.len();
|
let items = self.entries.len();
|
||||||
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
let bytes = self.zip.finish().map_err(fail)?.into_inner();
|
||||||
@@ -293,19 +234,6 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?;
|
.caused_by(trc::location!())?;
|
||||||
for message in cache.emails.items.iter() {
|
for message in cache.emails.items.iter() {
|
||||||
let mail_path = |folder: &str| {
|
|
||||||
format!(
|
|
||||||
"{base}mail/{}/{}.eml",
|
|
||||||
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
|
||||||
Id::from(message.document_id)
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let folder = message
|
|
||||||
.mailboxes
|
|
||||||
.first()
|
|
||||||
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
|
||||||
.map(|b| b.path.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let Some(metadata_) = data
|
let Some(metadata_) = data
|
||||||
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
.get_value::<Archive<AlignedBytes>>(ValueKey::property(
|
||||||
account_id,
|
account_id,
|
||||||
@@ -315,8 +243,6 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
))
|
))
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
// No date to check against the hold's range, so it's listed
|
|
||||||
out.missing(mail_path(&folder), &address, "email", &folder, None, false, NO_RECORD);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let metadata = metadata_
|
let metadata = metadata_
|
||||||
@@ -326,20 +252,20 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
if !keeping.covers(Some(received)) {
|
if !keeping.covers(Some(received)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let folder = message
|
||||||
|
.mailboxes
|
||||||
|
.first()
|
||||||
|
.and_then(|m| cache.mailboxes.items.iter().find(|b| b.document_id == m.mailbox_id))
|
||||||
|
.map(|b| b.path.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
let hash = types::blob_hash::BlobHash::from(&metadata.blob_hash);
|
||||||
match blob(server, hash.as_slice()).await? {
|
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||||
Some(bytes) => {
|
let path = format!(
|
||||||
out.add(mail_path(&folder), &bytes, &address, "email", &folder, Some(received as i64), false)?
|
"{base}mail/{}/{}.eml",
|
||||||
}
|
folder.split('/').map(segment).collect::<Vec<_>>().join("/"),
|
||||||
None => out.missing(
|
Id::from(message.document_id)
|
||||||
mail_path(&folder),
|
);
|
||||||
&address,
|
out.add(path, &bytes, &address, "email", &folder, Some(received as i64), false)?;
|
||||||
"email",
|
|
||||||
&folder,
|
|
||||||
Some(received as i64),
|
|
||||||
false,
|
|
||||||
NO_BLOB,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +315,6 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
))
|
))
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
out.missing(zip_path(Some(".ics")), &address, kind, folder, Some(*start), false, NO_RECORD);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
let event = event_.unarchive::<CalendarEvent>().caused_by(trc::location!())?;
|
||||||
@@ -405,7 +330,6 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
))
|
))
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
out.missing(zip_path(Some(".vcf")), &address, kind, folder, None, false, NO_RECORD);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
let card = card_.unarchive::<ContactCard>().caused_by(trc::location!())?;
|
||||||
@@ -422,18 +346,15 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
))
|
))
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
let file = file_.unarchive::<FileNode>().caused_by(trc::location!())?;
|
||||||
let Some(props) = file.file.as_ref() else {
|
let Some(props) = file.file.as_ref() else {
|
||||||
out.missing(zip_path(None), &address, kind, folder, None, false, NO_RECORD);
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
let hash = types::blob_hash::BlobHash::from(&props.blob_hash);
|
||||||
match blob(server, hash.as_slice()).await? {
|
if let Some(bytes) = blob(server, hash.as_slice()).await? {
|
||||||
Some(bytes) => out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?,
|
out.add(zip_path(None), &bytes, &address, kind, folder, None, false)?;
|
||||||
None => out.missing(zip_path(None), &address, kind, folder, None, false, NO_BLOB),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -467,10 +388,8 @@ pub async fn build(server: &Server, hold: &Hold, asked: &[u32]) -> trc::Result<(
|
|||||||
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
ArchivedItem::SieveScript(s) => format!("{}{ext}", segment(&s.name)),
|
||||||
_ => format!("{id}{ext}"),
|
_ => format!("{id}{ext}"),
|
||||||
};
|
};
|
||||||
let path = format!("{base}archived/{kind}/{name}");
|
if let Some(bytes) = blob(server, item.blob_id().hash.as_slice()).await? {
|
||||||
match blob(server, item.blob_id().hash.as_slice()).await? {
|
out.add(format!("{base}archived/{kind}/{name}"), &bytes, &address, kind, "", date, true)?;
|
||||||
Some(bytes) => out.add(path, &bytes, &address, kind, "", date, true)?,
|
|
||||||
None => out.missing(path, &address, kind, "", date, true, NO_BLOB),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,30 +425,5 @@ mod tests {
|
|||||||
let mut hash = String::new();
|
let mut hash = String::new();
|
||||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
||||||
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
assert!(hash.starts_with(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||||
// Nothing missed, and the file says so
|
|
||||||
let mut exceptions = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
|
||||||
assert_eq!(exceptions, "path,account,kind,folder,date,archived,reason\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn what_cant_be_read_is_listed_not_dropped() {
|
|
||||||
let mut b = Builder::new();
|
|
||||||
b.add("[email protected]/mail/INBOX/1.eml".into(), b"Subject: x\r\n\r\ny", "[email protected]", "email", "INBOX", Some(0), false)
|
|
||||||
.unwrap();
|
|
||||||
b.missing("[email protected]/mail/INBOX/2.eml".into(), "[email protected]", "email", "INBOX", Some(0), false, NO_BLOB);
|
|
||||||
let (bytes, items) = b.finish().unwrap();
|
|
||||||
assert_eq!(items, 1, "a missing item isn't counted as collected");
|
|
||||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
|
||||||
assert!(zip.by_name("[email protected]/mail/INBOX/2.eml").is_err());
|
|
||||||
let mut exceptions = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
|
||||||
assert!(
|
|
||||||
exceptions.contains("[email protected]/mail/INBOX/2.eml,[email protected],email,INBOX,") && exceptions.contains(NO_BLOB),
|
|
||||||
"{exceptions}"
|
|
||||||
);
|
|
||||||
let mut hash = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut zip.by_name("manifest.sha256").unwrap(), &mut hash).unwrap();
|
|
||||||
assert!(hash.contains(&format!("{} exceptions.csv", hex(&Sha256::digest(exceptions.as_bytes())))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
@@ -13,6 +15,8 @@ use mail_auth::{
|
|||||||
MX, RecordSet,
|
MX, RecordSet,
|
||||||
common::resolver::ToFqdn,
|
common::resolver::ToFqdn,
|
||||||
hickory_resolver::{
|
hickory_resolver::{
|
||||||
|
TokioResolver,
|
||||||
|
lookup::Lookup,
|
||||||
net::{DnsError, NetError},
|
net::{DnsError, NetError},
|
||||||
proto::{
|
proto::{
|
||||||
dnssec::Proof,
|
dnssec::Proof,
|
||||||
@@ -83,16 +87,15 @@ impl TlsaLookup for Server {
|
|||||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mx_lookup = match self
|
let (mx_lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
Name::from_str_relaxed::<&str>(key.as_ref())?,
|
||||||
.dnssec
|
RecordType::MX,
|
||||||
.resolver
|
)
|
||||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(mx_lookup) => mx_lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -144,7 +147,11 @@ impl TlsaLookup for Server {
|
|||||||
.collect::<Arc<[MX]>>();
|
.collect::<Arc<[MX]>>();
|
||||||
let records = RecordSet {
|
let records = RecordSet {
|
||||||
rrset,
|
rrset,
|
||||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
dnssec_status.unwrap_or(DnssecStatus::Indeterminate)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -285,16 +292,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::A,
|
||||||
.resolver
|
)
|
||||||
.ipv4_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -325,7 +331,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv4Addr]>>(),
|
.collect::<Arc<[Ipv4Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::A)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -363,16 +373,15 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||||
let lookup = match self
|
let (lookup, forced_insecure) = match validated_lookup(
|
||||||
.core
|
&self.core.smtp.resolvers.dnssec.resolver,
|
||||||
.smtp
|
self.core.smtp.resolvers.dns.resolver(),
|
||||||
.resolvers
|
name.clone(),
|
||||||
.dnssec
|
RecordType::AAAA,
|
||||||
.resolver
|
)
|
||||||
.ipv6_lookup(name.clone())
|
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(lookup) => lookup,
|
Ok(validated) => (validated.lookup, validated.insecure),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||||
&& denial.response_code == ResponseCode::NoError
|
&& denial.response_code == ResponseCode::NoError
|
||||||
@@ -403,7 +412,11 @@ impl TlsaLookup for Server {
|
|||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Arc<[Ipv6Addr]>>(),
|
.collect::<Arc<[Ipv6Addr]>>(),
|
||||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
dnssec_status: if forced_insecure {
|
||||||
|
DnssecStatus::Insecure
|
||||||
|
} else {
|
||||||
|
tlsa_base_status(&name, answers, RecordType::AAAA)
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
self.inner
|
self.inner
|
||||||
@@ -415,6 +428,115 @@ impl TlsaLookup for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: hickory 0.26.3 calls some valid answers bogus, and the queue then
|
||||||
|
// retries those hosts until the message expires. Two cases seen in production:
|
||||||
|
//
|
||||||
|
// - A zone delegated beneath an unsigned zone, such as `l.google.com` under
|
||||||
|
// `google.com`. To prove the delegation insecure, hickory wants an SOA
|
||||||
|
// record in the DS reply, and public resolvers often send none.
|
||||||
|
// - A signed CNAME to a signed name without the record type queried. Hickory
|
||||||
|
// checks the denial of existence against the name first asked for, not the
|
||||||
|
// target's, and rejects it.
|
||||||
|
//
|
||||||
|
// When hickory says bogus, check the answer again with lookups it gets right.
|
||||||
|
// A signed CNAME is followed and the lookup repeated at its target. Otherwise
|
||||||
|
// the name's zone and its parents are looked up, nearest first. If one
|
||||||
|
// validates as unsigned, nothing below it can be signed, so the plain resolver
|
||||||
|
// answers and the result is insecure. If one validates as signed first, the
|
||||||
|
// verdict stands.
|
||||||
|
|
||||||
|
const MAX_BOGUS_ALIASES: usize = 8;
|
||||||
|
|
||||||
|
struct ValidatedLookup {
|
||||||
|
lookup: Lookup,
|
||||||
|
insecure: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BogusRecheck {
|
||||||
|
Alias(Name),
|
||||||
|
Insecure,
|
||||||
|
Bogus,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validated_lookup(
|
||||||
|
dnssec: &TokioResolver,
|
||||||
|
plain: &TokioResolver,
|
||||||
|
name: Name,
|
||||||
|
record_type: RecordType,
|
||||||
|
) -> Result<ValidatedLookup, NetError> {
|
||||||
|
let mut query = name;
|
||||||
|
let mut aliases = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let err = match dnssec.lookup(query.clone(), record_type).await {
|
||||||
|
Ok(lookup) => {
|
||||||
|
return Ok(ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err @ NetError::Dns(DnsError::DnssecBogus)) => err,
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
};
|
||||||
|
|
||||||
|
match recheck_bogus(dnssec, &query).await {
|
||||||
|
BogusRecheck::Alias(target) if aliases < MAX_BOGUS_ALIASES => {
|
||||||
|
aliases += 1;
|
||||||
|
query = target;
|
||||||
|
}
|
||||||
|
BogusRecheck::Insecure => {
|
||||||
|
return plain
|
||||||
|
.lookup(query, record_type)
|
||||||
|
.await
|
||||||
|
.map(|lookup| ValidatedLookup {
|
||||||
|
lookup,
|
||||||
|
insecure: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
BogusRecheck::Alias(_) | BogusRecheck::Bogus => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recheck_bogus(dnssec: &TokioResolver, name: &Name) -> BogusRecheck {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(name.clone(), RecordType::CNAME).await
|
||||||
|
&& let Some(target) = secure_alias(name, lookup.answers())
|
||||||
|
{
|
||||||
|
return BogusRecheck::Alias(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut zone = name.clone();
|
||||||
|
while !zone.is_root() {
|
||||||
|
if let Ok(lookup) = dnssec.lookup(zone.clone(), RecordType::SOA).await {
|
||||||
|
match apex_status(&zone, lookup.answers()) {
|
||||||
|
Some(DnssecStatus::Insecure) => return BogusRecheck::Insecure,
|
||||||
|
Some(DnssecStatus::Secure) => return BogusRecheck::Bogus,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
zone = zone.base_name();
|
||||||
|
}
|
||||||
|
|
||||||
|
BogusRecheck::Bogus
|
||||||
|
}
|
||||||
|
|
||||||
|
fn secure_alias(query: &Name, answers: &[Record]) -> Option<Name> {
|
||||||
|
answers.iter().find_map(|record| match &record.data {
|
||||||
|
RData::CNAME(target) if &record.name == query && record.proof.is_secure() => {
|
||||||
|
Some(target.0.clone())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apex_status(zone: &Name, answers: &[Record]) -> Option<DnssecStatus> {
|
||||||
|
answers
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.record_type() == RecordType::SOA && &record.name == zone)
|
||||||
|
.map(|record| proof_to_dnssec_status(record.proof))
|
||||||
|
.reduce(least_secure)
|
||||||
|
}
|
||||||
|
|
||||||
struct NegativeAnswer {
|
struct NegativeAnswer {
|
||||||
response_code: ResponseCode,
|
response_code: ResponseCode,
|
||||||
dnssec_status: DnssecStatus,
|
dnssec_status: DnssecStatus,
|
||||||
@@ -511,7 +633,7 @@ pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME, SOA};
|
||||||
use std::net::Ipv4Addr;
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
fn name(value: &str) -> Name {
|
fn name(value: &str) -> Name {
|
||||||
@@ -624,4 +746,127 @@ mod tests {
|
|||||||
DnssecStatus::Insecure
|
DnssecStatus::Insecure
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn soa(owner: &str, proof: Proof) -> Record {
|
||||||
|
let mut record = Record::from_rdata(
|
||||||
|
name(owner),
|
||||||
|
3600,
|
||||||
|
RData::SOA(SOA::new(
|
||||||
|
name("ns1.example.org."),
|
||||||
|
name("hostmaster.example.org."),
|
||||||
|
1,
|
||||||
|
900,
|
||||||
|
900,
|
||||||
|
1800,
|
||||||
|
60,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
record.proof = proof;
|
||||||
|
record
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_follows_signed_cname() {
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&name("mail.example.org."),
|
||||||
|
&[alias("mail.example.org.", "mx.example.net.", Proof::Secure)]
|
||||||
|
),
|
||||||
|
Some(name("mx.example.net."))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_alias_ignores_unsigned_or_other_cname() {
|
||||||
|
let query = name("mail.example.org.");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"mail.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Insecure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
secure_alias(
|
||||||
|
&query,
|
||||||
|
&[alias(
|
||||||
|
"other.example.org.",
|
||||||
|
"mx.example.net.",
|
||||||
|
Proof::Secure
|
||||||
|
)]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_reads_the_zone_soa() {
|
||||||
|
let zone = name("example.com.");
|
||||||
|
|
||||||
|
for (proof, expected) in [
|
||||||
|
(Proof::Secure, Some(DnssecStatus::Secure)),
|
||||||
|
(Proof::Insecure, Some(DnssecStatus::Insecure)),
|
||||||
|
(Proof::Bogus, Some(DnssecStatus::Bogus)),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(&zone, &[soa("example.com.", proof)]),
|
||||||
|
expected,
|
||||||
|
"proof {proof}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apex_status_ignores_other_records() {
|
||||||
|
assert_eq!(
|
||||||
|
apex_status(
|
||||||
|
&name("example.com."),
|
||||||
|
&[
|
||||||
|
soa("sub.example.com.", Proof::Insecure),
|
||||||
|
address("example.com.", Proof::Insecure),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs the network: a signed MX pointing into a zone delegated beneath an
|
||||||
|
// unsigned one. Run with `--ignored` to check a hickory upgrade.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn validated_lookup_proves_delegation_below_unsigned_zone() {
|
||||||
|
use mail_auth::hickory_resolver::{
|
||||||
|
config::{CLOUDFLARE, ResolverConfig, ResolverOpts},
|
||||||
|
net::runtime::TokioRuntimeProvider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let build = |validate: bool| {
|
||||||
|
// Same options as the server's DNSSEC resolver; hickory fails
|
||||||
|
// validation with concurrent requests.
|
||||||
|
let mut opts = ResolverOpts::default();
|
||||||
|
opts.validate = validate;
|
||||||
|
opts.num_concurrent_reqs = 1;
|
||||||
|
opts.cache_size = 0;
|
||||||
|
TokioResolver::builder_with_config(
|
||||||
|
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
|
||||||
|
TokioRuntimeProvider::default(),
|
||||||
|
)
|
||||||
|
.with_options(opts)
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let (dnssec, plain) = (build(true), build(false));
|
||||||
|
|
||||||
|
let validated =
|
||||||
|
validated_lookup(&dnssec, &plain, name("aspmx.l.google.com."), RecordType::A)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(validated.insecure);
|
||||||
|
assert!(!validated.lookup.answers().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ use mail_auth::{
|
|||||||
common::verify::VerifySignature,
|
common::verify::VerifySignature,
|
||||||
dkim2::Dkim2Output,
|
dkim2::Dkim2Output,
|
||||||
dmarc::{self},
|
dmarc::{self},
|
||||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
report::{
|
||||||
|
ActionDisposition, AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report,
|
||||||
|
SPFDomainScope,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
schema::{
|
schema::{
|
||||||
@@ -459,7 +462,7 @@ impl DmarcReporting for Server {
|
|||||||
.await
|
.await
|
||||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||||
let mut message = Vec::with_capacity(2048);
|
let mut message = Vec::with_capacity(2048);
|
||||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
let _ = with_compatible_dispositions(Report::from(report.report)).write_rfc5322(
|
||||||
&self
|
&self
|
||||||
.eval_if(
|
.eval_if(
|
||||||
&self.core.smtp.report.submitter,
|
&self.core.smtp.report.submitter,
|
||||||
@@ -710,3 +713,55 @@ impl DmarcReporting for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: RFC 9990 added "pass" to the evaluated disposition for mail that
|
||||||
|
// passed DMARC under an enforcing policy. Cloudflare's report intake rejects
|
||||||
|
// the whole report with "555 5.7.1 invalid_report_schema" when it sees that
|
||||||
|
// value, and older parsers built on the RFC 7489 schema do the same. "none"
|
||||||
|
// (no action taken) is valid under both and says the same thing, so reports
|
||||||
|
// go out with that instead.
|
||||||
|
fn with_compatible_dispositions(mut report: Report) -> Report {
|
||||||
|
for record in &mut report.record {
|
||||||
|
let disposition = &mut record.row.policy_evaluated.disposition;
|
||||||
|
if *disposition == ActionDisposition::Pass {
|
||||||
|
*disposition = ActionDisposition::None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use mail_auth::report::DmarcResult;
|
||||||
|
|
||||||
|
fn record(disposition: ActionDisposition) -> Record {
|
||||||
|
Record::new()
|
||||||
|
.with_source_ip("192.0.2.1".parse().unwrap())
|
||||||
|
.with_count(1)
|
||||||
|
.with_action_disposition(disposition)
|
||||||
|
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||||
|
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||||
|
.with_header_from("example.org")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pass_disposition_is_reported_as_none() {
|
||||||
|
let xml = with_compatible_dispositions(
|
||||||
|
Report::new()
|
||||||
|
.with_domain("example.org")
|
||||||
|
.with_record(record(ActionDisposition::Pass))
|
||||||
|
.with_record(record(ActionDisposition::Quarantine))
|
||||||
|
.with_record(record(ActionDisposition::Reject)),
|
||||||
|
)
|
||||||
|
.to_xml();
|
||||||
|
|
||||||
|
assert!(!xml.contains("<disposition>pass</disposition>"), "{xml}");
|
||||||
|
assert!(xml.contains("<disposition>none</disposition>"), "{xml}");
|
||||||
|
assert!(
|
||||||
|
xml.contains("<disposition>quarantine</disposition>"),
|
||||||
|
"{xml}"
|
||||||
|
);
|
||||||
|
assert!(xml.contains("<disposition>reject</disposition>"), "{xml}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
|||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! brand_version {
|
macro_rules! brand_version {
|
||||||
() => {
|
() => {
|
||||||
"2026.9.28.1"
|
"2026.9.28.2"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -174,7 +176,8 @@ async fn report_dmarc() {
|
|||||||
let source_ip = record.source_ip().unwrap();
|
let source_ip = record.source_ip().unwrap();
|
||||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||||
assert_eq!(record.count(), 2);
|
assert_eq!(record.count(), 2);
|
||||||
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
|
// inbuxa: "pass" goes out as "none" for RFC 7489 parsers
|
||||||
|
assert_eq!(record.action_disposition(), ActionDisposition::None);
|
||||||
assert_eq!(record.envelope_from(), "[email protected]");
|
assert_eq!(record.envelope_from(), "[email protected]");
|
||||||
assert_eq!(record.header_from(), "[email protected]");
|
assert_eq!(record.header_from(), "[email protected]");
|
||||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
assert!(
|
assert!(
|
||||||
names
|
names
|
||||||
.iter()
|
.iter()
|
||||||
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.") || n == "exceptions.csv"),
|
.all(|n| n.starts_with("[email protected]/") || n.starts_with("manifest.")),
|
||||||
"LH-12: an account the hold doesn't cover was exported: {names:?}"
|
"LH-12: an account the hold doesn't cover was exported: {names:?}"
|
||||||
);
|
);
|
||||||
let mut manifest = String::new();
|
let mut manifest = String::new();
|
||||||
@@ -508,12 +508,6 @@ pub async fn test(test: &mut TestServer) {
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
|
assert!(hash.starts_with(&expected), "LH-12: the manifest's hash doesn't match");
|
||||||
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
|
assert!(manifest.contains(",true,"), "LH-12: nothing marked archived: {manifest}");
|
||||||
let mut exceptions = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut zip.by_name("exceptions.csv").unwrap(), &mut exceptions).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
exceptions, "path,account,kind,folder,date,archived,reason\n",
|
|
||||||
"LH-12: items the hold covers couldn't be read"
|
|
||||||
);
|
|
||||||
// LH-13: only sysLegalHoldExport starts one
|
// LH-13: only sysLegalHoldExport starts one
|
||||||
let (_, response) = frozen
|
let (_, response) = frozen
|
||||||
.hold_call(
|
.hold_call(
|
||||||
|
|||||||
Reference in New Issue
Block a user