crates/monetize-server/src/estate_order/domain.rs
//! **The customer's own domain** (owner, 2026-10-04 18:00): besides
//! `<company>.gunnar.rs`, whose DNS we write, a company may run its gunnar
//! under a name of its own — `gunnar.company1.com`, `gunnar.company2.se`.
//!
//! We do not hold that DNS, so the customer sets two records and the job
//! waits for them (`waiting_dns`), with a deadline:
//!
//! * `CNAME <domain> → <company>.<apex>` — the name leads to the estate's
//! front (whose own records we wrote), so Let's Encrypt's HTTP-01 reaches
//! the front and gunnar-front's existing ACME makes the certificate;
//! * `TXT _<proof>.<domain> = <proof>=<token>` — the proof that whoever ordered
//! controls the name; `<proof>` is the product's
//! ([`super::lifecycle::Lifecycle::proof_name`], Gunnar's `gunnar-proof`). The token is random and public: it is
//! a claim to be published, not a secret.
//!
//! The records are read through a [`DomainCheck`]: the account's is a real
//! resolver, [`DnsResolver`] — the DNS protocol itself over UDP (TCP when an
//! answer is truncated), asking the nameservers `resolver = "dns"` reads from
//! `/etc/resolv.conf` or the ones `resolver = "dns:<ip:port>,…"` names; the
//! mock's is a file ([`FileResolver`]), and the tests' resolver is a fake
//! nameserver on loopback that [`DnsResolver`] itself asks.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
/// The label the ownership proof named `proof` is published under (`_gunnar-proof`).
pub fn proof_label(proof: &str) -> String {
format!("_{proof}")
}
/// **Why `domain` cannot be a customer's estate name**, or `None`. A host
/// name of at least two labels, lowercase letters, digits and hyphens, not
/// under our own apex (those are ours, not the customer's).
pub fn domain_refusal(domain: &str, apex: &str) -> Option<String> {
if domain.len() > 253 || !domain.contains('.') {
return Some(format!(
"{domain:?} is not a domain name like shop.example.com"
));
}
if domain == apex || domain.ends_with(&format!(".{apex}")) {
return Some(format!(
"{domain} is under {apex}: order <company>.{apex} instead, whose DNS we write"
));
}
for label in domain.split('.') {
let ok = !label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !ok {
return Some(format!("{domain:?} is not a domain name: lowercase letters, digits and hyphens, label by label"));
}
}
None
}
/// One record the customer must set, as the page shows it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct CustomerRecord {
pub name: String,
pub kind: String,
pub value: String,
}
/// **The two records the customer sets**, the proof named `proof` (the product's).
pub fn required_as(
proof: &str,
domain: &str,
company: &str,
apex: &str,
token: &str,
) -> Vec<CustomerRecord> {
vec![
CustomerRecord {
name: domain.into(),
kind: "CNAME".into(),
value: format!("{company}.{apex}"),
},
CustomerRecord {
name: format!("{}.{domain}", proof_label(proof)),
kind: "TXT".into(),
value: format!("{proof}={token}"),
},
]
}
/// **How the world's DNS is read.**
pub trait DomainCheck: Send + Sync {
/// The CNAME target of `name`, without a trailing dot; `None` when it has none.
fn cname(&self, name: &str) -> Result<Option<String>, String>;
/// Every TXT string at `name`.
fn txt(&self, name: &str) -> Result<Vec<String>, String>;
/// Every A record's address at `name` (the shop's own names, set by hand: `ManualDns`).
fn a(&self, name: &str) -> Result<Vec<String>, String> {
let _ = name;
Err("this resolver reads no A records".into())
}
/// Whether this checker can read a customer's domain at all — `false`
/// for [`NoResolver`], so `Start` refuses an own domain at once instead
/// of taking the money for an order that can only time out (A57).
fn reads(&self) -> bool {
true
}
}
/// No resolver here: a customer's domain cannot be checked, and the job says so.
pub struct NoResolver;
impl DomainCheck for NoResolver {
fn cname(&self, _name: &str) -> Result<Option<String>, String> {
Err("no resolver is configured here ([estate_orders] resolver)".into())
}
fn txt(&self, _name: &str) -> Result<Vec<String>, String> {
Err("no resolver is configured here ([estate_orders] resolver)".into())
}
fn reads(&self) -> bool {
false
}
}
/// **The mock's world DNS**: a JSON list of `{name, kind, value}` the test —
/// the customer — writes.
pub struct FileResolver(pub PathBuf);
impl FileResolver {
fn rows(&self) -> Result<Vec<CustomerRecord>, String> {
match std::fs::read(&self.0) {
Ok(b) if b.is_empty() => Ok(Vec::new()),
Ok(b) => serde_json::from_slice(&b).map_err(|e| format!("{}: {e}", self.0.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("{}: {e}", self.0.display())),
}
}
/// The customer sets a record (the mock's side of their DNS provider).
pub fn set(&self, r: CustomerRecord) -> Result<(), String> {
let mut rows = self.rows()?;
rows.retain(|x| !(x.name == r.name && x.kind == r.kind));
rows.push(r);
std::fs::write(
&self.0,
serde_json::to_vec_pretty(&rows).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("{}: {e}", self.0.display()))
}
}
impl DomainCheck for FileResolver {
fn cname(&self, name: &str) -> Result<Option<String>, String> {
Ok(self
.rows()?
.into_iter()
.find(|r| r.name == name && r.kind == "CNAME")
.map(|r| r.value.trim_end_matches('.').to_string()))
}
fn txt(&self, name: &str) -> Result<Vec<String>, String> {
Ok(self
.rows()?
.into_iter()
.filter(|r| r.name == name && r.kind == "TXT")
.map(|r| r.value)
.collect())
}
fn a(&self, name: &str) -> Result<Vec<String>, String> {
Ok(self
.rows()?
.into_iter()
.filter(|r| r.name == name && r.kind == "A")
.map(|r| r.value)
.collect())
}
}
/// **The resolver `[estate_orders] resolver` names**: `file:<path>` (the
/// mock's world DNS), `dns` (the nameservers of `/etc/resolv.conf`),
/// `dns:<ip:port>,…` (these), or empty (none: a customer's domain is refused
/// at `Start`).
pub fn resolver_of(word: &str) -> Result<Arc<dyn DomainCheck>, String> {
let w = word.trim();
if let Some(p) = w.strip_prefix("file:") {
return Ok(Arc::new(FileResolver(PathBuf::from(p))));
}
if w.is_empty() {
return Ok(Arc::new(NoResolver));
}
if w == "dns" {
let text = std::fs::read_to_string("/etc/resolv.conf")
.map_err(|e| format!("[estate_orders] resolver = \"dns\": /etc/resolv.conf: {e}"))?;
return Ok(Arc::new(DnsResolver::new(DnsResolver::system(&text)?)));
}
if let Some(list) = w.strip_prefix("dns:") {
let servers = list
.split(',')
.map(|a| {
a.trim().parse::<SocketAddr>().map_err(|e| {
format!("[estate_orders] resolver {w:?}: {a:?} is not ip:port ({e})")
})
})
.collect::<Result<Vec<_>, _>>()?;
if servers.is_empty() {
return Err(format!(
"[estate_orders] resolver {w:?} names no nameserver"
));
}
return Ok(Arc::new(DnsResolver::new(servers)));
}
Err(format!(
"[estate_orders] resolver {w:?} is not `file:<path>`, `dns`, `dns:<ip:port>,…` or empty"
))
}
/// **A real resolver**: the DNS protocol (RFC 1035) to recursive
/// nameservers — a question with recursion desired, the answer read for the
/// CNAME or the TXT strings asked for. UDP first; an answer marked truncated
/// is asked again over TCP. Each nameserver in turn until one answers; an
/// NXDOMAIN or an empty answer is "no record", never an error.
pub struct DnsResolver {
pub servers: Vec<SocketAddr>,
pub timeout: Duration,
}
/// The record types asked for.
const T_A: u16 = 1;
const T_CNAME: u16 = 5;
const T_TXT: u16 = 16;
impl DnsResolver {
pub fn new(servers: Vec<SocketAddr>) -> DnsResolver {
DnsResolver {
servers,
timeout: Duration::from_secs(3),
}
}
/// The `nameserver` lines of a resolv.conf, port 53.
pub fn system(resolv_conf: &str) -> Result<Vec<SocketAddr>, String> {
let out: Vec<SocketAddr> = resolv_conf
.lines()
.filter_map(|l| l.trim().strip_prefix("nameserver"))
.filter_map(|ip| ip.trim().parse::<std::net::IpAddr>().ok())
.map(|ip| SocketAddr::new(ip, 53))
.collect();
if out.is_empty() {
return Err("/etc/resolv.conf names no nameserver".into());
}
Ok(out)
}
/// Ask every nameserver in turn; the answer's records of `qtype`.
fn ask(&self, name: &str, qtype: u16) -> Result<Vec<wire::Record>, String> {
let mut last = format!("no nameserver to ask for {name}");
for at in &self.servers {
match self.ask_one(*at, name, qtype) {
Ok(rows) => return Ok(rows),
Err(e) => last = format!("{at}: {e}"),
}
}
Err(format!("the DNS could not be asked about {name}: {last}"))
}
fn ask_one(&self, at: SocketAddr, name: &str, qtype: u16) -> Result<Vec<wire::Record>, String> {
let id = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(7)
& 0xffff) as u16;
let q = wire::query(id, name, qtype)?;
let bind = if at.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" };
let sock = std::net::UdpSocket::bind(bind).map_err(|e| e.to_string())?;
sock.set_read_timeout(Some(self.timeout))
.map_err(|e| e.to_string())?;
sock.connect(at).map_err(|e| e.to_string())?;
let mut answer = None;
for _ in 0..2 {
sock.send(&q).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 4096];
match sock.recv(&mut buf) {
Ok(n) => {
buf.truncate(n);
if buf.len() >= 2 && u16::from_be_bytes([buf[0], buf[1]]) == id {
answer = Some(buf);
break;
}
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) =>
{
continue
}
Err(e) => return Err(e.to_string()),
}
}
let mut msg = answer
.ok_or_else(|| format!("no answer within {} s, twice", self.timeout.as_secs()))?;
if wire::truncated(&msg) {
msg = self.ask_tcp(at, &q, id)?;
}
wire::answers(&msg, id)
}
fn ask_tcp(&self, at: SocketAddr, q: &[u8], id: u16) -> Result<Vec<u8>, String> {
use std::io::{Read, Write};
let mut s = std::net::TcpStream::connect_timeout(&at, self.timeout)
.map_err(|e| format!("over TCP: {e}"))?;
s.set_read_timeout(Some(self.timeout))
.map_err(|e| e.to_string())?;
let mut framed = (q.len() as u16).to_be_bytes().to_vec();
framed.extend_from_slice(q);
s.write_all(&framed).map_err(|e| format!("over TCP: {e}"))?;
let mut len = [0u8; 2];
s.read_exact(&mut len)
.map_err(|e| format!("over TCP: {e}"))?;
let mut msg = vec![0u8; u16::from_be_bytes(len) as usize];
s.read_exact(&mut msg)
.map_err(|e| format!("over TCP: {e}"))?;
if msg.len() < 2 || u16::from_be_bytes([msg[0], msg[1]]) != id {
return Err("over TCP: an answer to another question".into());
}
Ok(msg)
}
}
impl DomainCheck for DnsResolver {
fn cname(&self, name: &str) -> Result<Option<String>, String> {
let rows = self.ask(name, T_CNAME)?;
let want = name.trim_end_matches('.').to_ascii_lowercase();
// A CNAME's rdata is its target, decoded by `wire::answers`.
Ok(rows
.iter()
.find(|(owner, kind, _, _)| *kind == T_CNAME && owner.eq_ignore_ascii_case(&want))
.map(|r| {
String::from_utf8_lossy(&r.2)
.trim_end_matches('.')
.to_ascii_lowercase()
}))
}
fn txt(&self, name: &str) -> Result<Vec<String>, String> {
let rows = self.ask(name, T_TXT)?;
Ok(rows
.iter()
.filter(|r| r.1 == T_TXT)
.map(|r| wire::txt(&r.2))
.collect())
}
fn a(&self, name: &str) -> Result<Vec<String>, String> {
let rows = self.ask(name, T_A)?;
Ok(rows
.iter()
.filter(|r| r.1 == T_A && r.2.len() == 4)
.map(|r| std::net::Ipv4Addr::new(r.2[0], r.2[1], r.2[2], r.2[3]).to_string())
.collect())
}
}
/// **The DNS message, written and read** (RFC 1035 §4): only what the two
/// questions need.
pub mod wire {
/// One answer record: `(owner, type, rdata, rdata offset)`.
pub type Record = (String, u16, Vec<u8>, usize);
/// A question for `name`/`qtype`, class IN, recursion desired.
pub fn query(id: u16, name: &str, qtype: u16) -> Result<Vec<u8>, String> {
let mut m = Vec::with_capacity(64);
m.extend_from_slice(&id.to_be_bytes());
m.extend_from_slice(&0x0100u16.to_be_bytes()); // RD
m.extend_from_slice(&1u16.to_be_bytes()); // QDCOUNT
m.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
put_name(&mut m, name)?;
m.extend_from_slice(&qtype.to_be_bytes());
m.extend_from_slice(&1u16.to_be_bytes());
Ok(m)
}
pub fn put_name(m: &mut Vec<u8>, name: &str) -> Result<(), String> {
for label in name.trim_end_matches('.').split('.') {
if label.is_empty() || label.len() > 63 {
return Err(format!("{name:?} is not a DNS name"));
}
m.push(label.len() as u8);
m.extend_from_slice(label.as_bytes());
}
m.push(0);
Ok(())
}
/// The TC bit.
pub fn truncated(m: &[u8]) -> bool {
m.len() >= 4 && m[2] & 0x02 != 0
}
/// A (possibly compressed) name at `at`; answers it and where it ends.
pub fn name(m: &[u8], mut at: usize) -> Result<(String, usize), String> {
let mut labels: Vec<String> = Vec::new();
let mut end = None;
for _ in 0..128 {
let len = *m.get(at).ok_or("a name runs past the message")? as usize;
if len == 0 {
return Ok((labels.join("."), end.unwrap_or(at + 1)));
}
if len & 0xc0 == 0xc0 {
let lo = *m.get(at + 1).ok_or("a pointer runs past the message")? as usize;
end.get_or_insert(at + 2);
at = ((len & 0x3f) << 8) | lo;
continue;
}
let label = m
.get(at + 1..at + 1 + len)
.ok_or("a label runs past the message")?;
labels.push(String::from_utf8_lossy(label).to_ascii_lowercase());
at += 1 + len;
}
Err("a name loops".into())
}
/// **The answer section**: `(owner, type, rdata, rdata offset)` per
/// record — a CNAME's rdata decoded to its target's text. NXDOMAIN is
/// no records; any other error code is an error.
pub fn answers(m: &[u8], id: u16) -> Result<Vec<Record>, String> {
if m.len() < 12 || u16::from_be_bytes([m[0], m[1]]) != id {
return Err("an answer to another question".into());
}
if m[2] & 0x80 == 0 {
return Err("not an answer".into());
}
match m[3] & 0x0f {
0 => {}
3 => return Ok(Vec::new()),
rc => return Err(format!("the nameserver answered error {rc}")),
}
let qd = u16::from_be_bytes([m[4], m[5]]) as usize;
let an = u16::from_be_bytes([m[6], m[7]]) as usize;
let mut at = 12;
for _ in 0..qd {
at = name(m, at)?.1 + 4;
}
let mut out = Vec::with_capacity(an);
for _ in 0..an {
let (owner, next) = name(m, at)?;
let head = m
.get(next..next + 10)
.ok_or("a record runs past the message")?;
let kind = u16::from_be_bytes([head[0], head[1]]);
let len = u16::from_be_bytes([head[8], head[9]]) as usize;
let start = next + 10;
let rdata = m
.get(start..start + len)
.ok_or("a record's data runs past the message")?;
let rdata = if kind == super::T_CNAME {
name(m, start)?.0.into_bytes()
} else {
rdata.to_vec()
};
out.push((owner, kind, rdata, start));
at = start + len;
}
Ok(out)
}
/// A TXT record's strings, joined (RFC 7208 §3.3).
pub fn txt(rdata: &[u8]) -> String {
let mut out = String::new();
let mut i = 0;
while i < rdata.len() {
let n = rdata[i] as usize;
let end = (i + 1 + n).min(rdata.len());
out.push_str(&String::from_utf8_lossy(&rdata[i + 1..end]));
i = end;
}
out
}
}
/// **Are both records in place?**, the proof named `proof` (the product's). `Ok(None)`: yes.
/// `Ok(Some(what))`: not yet, and what is missing. `Err`: the resolver could not be asked.
pub fn verify_as(
proof_name: &str,
check: &dyn DomainCheck,
domain: &str,
company: &str,
apex: &str,
token: &str,
) -> Result<Option<String>, String> {
let want = format!("{company}.{apex}");
let mut missing = Vec::new();
match check.cname(domain)? {
Some(t) if t == want => {}
Some(t) => missing.push(format!("{domain} is a CNAME to {t}, not to {want}")),
None => missing.push(format!("{domain} has no CNAME to {want}")),
}
let proof = format!("{proof_name}={token}");
let label = proof_label(proof_name);
if !check
.txt(&format!("{label}.{domain}"))?
.iter()
.any(|t| t == &proof)
{
missing.push(format!("{label}.{domain} has no TXT {proof}"));
}
Ok((!missing.is_empty()).then(|| missing.join("; ")))
}
#[cfg(test)]
mod tests {
use super::*;
/// The records a Gunnar-kind estate's own domain carries (its proof `gunnar-proof`).
fn required(domain: &str, company: &str, apex: &str, token: &str) -> Vec<CustomerRecord> {
required_as("gunnar-proof", domain, company, apex, token)
}
/// [`verify_as`] for a Gunnar-kind estate.
fn verify(
check: &dyn DomainCheck,
domain: &str,
company: &str,
apex: &str,
token: &str,
) -> Result<Option<String>, String> {
verify_as("gunnar-proof", check, domain, company, apex, token)
}
#[test]
fn a_customer_domain_is_a_host_name_not_under_our_apex() {
for ok in [
"gunnar.company1.com",
"gunnar.company2.se",
"git-host.example.org",
] {
assert_eq!(domain_refusal(ok, "gunnar.rs"), None, "{ok}");
}
for bad in [
"company",
"acme.gunnar.rs",
"gunnar.rs",
"Gunnar.Example.com",
"a..b",
"-a.com",
"a_b.com",
] {
assert!(domain_refusal(bad, "gunnar.rs").is_some(), "{bad}");
}
}
#[test]
fn both_records_must_be_in_place() {
let f = std::env::temp_dir().join(format!("monetize-domain-{}.json", std::process::id()));
let _ = std::fs::remove_file(&f);
let r = FileResolver(f.clone());
let missing = verify(&r, "gunnar.company1.com", "acme", "gunnar.rs", "t0k")
.unwrap()
.unwrap();
assert!(
missing.contains("no CNAME") && missing.contains("no TXT"),
"{missing}"
);
r.set(CustomerRecord {
name: "gunnar.company1.com".into(),
kind: "CNAME".into(),
value: "acme.gunnar.rs.".into(),
})
.unwrap();
assert!(
verify(&r, "gunnar.company1.com", "acme", "gunnar.rs", "t0k")
.unwrap()
.unwrap()
.contains("no TXT")
);
for rec in required("gunnar.company1.com", "acme", "gunnar.rs", "t0k") {
r.set(rec).unwrap();
}
assert_eq!(
verify(&r, "gunnar.company1.com", "acme", "gunnar.rs", "t0k").unwrap(),
None
);
assert!(verify(&NoResolver, "x.com", "acme", "gunnar.rs", "t").is_err());
let _ = std::fs::remove_file(&f);
}
// ── the real resolver, against a fake nameserver on loopback ────────────
/// **A nameserver that knows a few records** — the tests' "world DNS":
/// answers a question for one of `records` (name, type, rdata) with it,
/// anything else NXDOMAIN. `truncate_udp`: every UDP answer is empty with
/// the TC bit set, and the full answer is served over TCP on the same port.
pub(crate) fn fake_nameserver(
records: Vec<(&'static str, u16, Vec<u8>)>,
truncate_udp: bool,
) -> SocketAddr {
let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let at = udp.local_addr().unwrap();
let records = std::sync::Arc::new(records);
let answer = {
let records = records.clone();
move |q: &[u8], truncated: bool| -> Vec<u8> {
let (qname, end) = wire::name(q, 12).unwrap();
let qtype = u16::from_be_bytes([q[end], q[end + 1]]);
let hits: Vec<&(&str, u16, Vec<u8>)> = records
.iter()
.filter(|r| r.0 == qname && (r.1 == qtype || r.1 == T_CNAME))
.collect();
let mut m = q[..2].to_vec();
let rcode = if hits.is_empty() { 3 } else { 0 };
m.extend_from_slice(&[0x81 | if truncated { 0x02 } else { 0 }, 0x80 | rcode]);
m.extend_from_slice(&1u16.to_be_bytes());
m.extend_from_slice(
&(if truncated { 0u16 } else { hits.len() as u16 }).to_be_bytes(),
);
m.extend_from_slice(&[0, 0, 0, 0]);
m.extend_from_slice(&q[12..end + 4]);
if !truncated {
for (_, kind, rdata) in hits {
m.extend_from_slice(&[0xc0, 12]);
m.extend_from_slice(&kind.to_be_bytes());
m.extend_from_slice(&1u16.to_be_bytes());
m.extend_from_slice(&300u32.to_be_bytes());
m.extend_from_slice(&(rdata.len() as u16).to_be_bytes());
m.extend_from_slice(rdata);
}
}
m
}
};
let udp_answer = answer.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 512];
while let Ok((n, from)) = udp.recv_from(&mut buf) {
let _ = udp.send_to(&udp_answer(&buf[..n], truncate_udp), from);
}
});
if truncate_udp {
let tcp = std::net::TcpListener::bind(at).unwrap();
std::thread::spawn(move || {
use std::io::{Read, Write};
for s in tcp.incoming().flatten() {
let mut s = s;
let mut len = [0u8; 2];
if s.read_exact(&mut len).is_err() {
continue;
}
let mut q = vec![0u8; u16::from_be_bytes(len) as usize];
if s.read_exact(&mut q).is_err() {
continue;
}
let a = answer(&q, false);
let mut framed = (a.len() as u16).to_be_bytes().to_vec();
framed.extend_from_slice(&a);
let _ = s.write_all(&framed);
}
});
}
at
}
/// A name in wire form (an uncompressed CNAME target).
fn name_rdata(n: &str) -> Vec<u8> {
let mut v = Vec::new();
wire::put_name(&mut v, n).unwrap();
v
}
/// TXT rdata of these strings.
fn txt_rdata(parts: &[&str]) -> Vec<u8> {
parts
.iter()
.flat_map(|p| std::iter::once(p.len() as u8).chain(p.bytes()))
.collect()
}
/// ★ **The account's resolver is the DNS itself**: the CNAME and the
/// proof are read from a nameserver (a TXT split into two strings is one
/// value), a name nobody set is NXDOMAIN — "missing", not an error — and
/// the check passes only with both in place.
#[test]
fn a_real_resolver_reads_the_cname_and_the_proof_from_a_nameserver() {
let at = fake_nameserver(
vec![
(
"gunnar.company1.com",
T_CNAME,
name_rdata("acme.gunnar.rs."),
),
(
"_gunnar-proof.gunnar.company1.com",
T_TXT,
txt_rdata(&["gunnar-proof=", "t0k"]),
),
(
"gunnar.company2.se",
T_CNAME,
name_rdata("someone-else.example."),
),
],
false,
);
let r = resolver_of(&format!("dns:{at}")).unwrap();
assert_eq!(
r.cname("gunnar.company1.com").unwrap().as_deref(),
Some("acme.gunnar.rs")
);
assert_eq!(
r.txt("_gunnar-proof.gunnar.company1.com").unwrap(),
vec!["gunnar-proof=t0k".to_string()]
);
assert_eq!(
r.cname("nobody.example.org").unwrap(),
None,
"NXDOMAIN is no record"
);
assert_eq!(
verify(
r.as_ref(),
"gunnar.company1.com",
"acme",
"gunnar.rs",
"t0k"
)
.unwrap(),
None
);
let missing = verify(r.as_ref(), "gunnar.company2.se", "acme", "gunnar.rs", "t0k")
.unwrap()
.unwrap();
assert!(
missing.contains("CNAME to someone-else.example, not to acme.gunnar.rs")
&& missing.contains("no TXT"),
"{missing}"
);
}
/// An answer too big for UDP is marked truncated; the resolver asks the
/// same nameserver again over TCP.
#[test]
fn a_truncated_answer_is_asked_again_over_tcp() {
let at = fake_nameserver(
vec![(
"_gunnar-proof.gunnar.company1.com",
T_TXT,
txt_rdata(&["gunnar-proof=t0k"]),
)],
true,
);
let r = DnsResolver::new(vec![at]);
assert_eq!(
r.txt("_gunnar-proof.gunnar.company1.com").unwrap(),
vec!["gunnar-proof=t0k".to_string()]
);
}
/// A nameserver that does not answer is an ERROR (the job keeps waiting
/// and says so), never "no record"; the config words are refused by name.
#[test]
fn a_silent_nameserver_is_an_error_and_the_config_words_are_checked() {
let dead = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let mut r = DnsResolver::new(vec![dead.local_addr().unwrap()]);
r.timeout = Duration::from_millis(200);
assert!(r.cname("gunnar.company1.com").is_err());
assert!(
resolver_of("dns:").is_err()
&& resolver_of("dns:not-an-address").is_err()
&& resolver_of("bind9").is_err()
);
assert!(resolver_of("").is_ok() && resolver_of("file:/tmp/x.json").is_ok());
assert_eq!(
DnsResolver::system("# c\nnameserver 127.0.0.53\noptions edns0\nnameserver ::1\n")
.unwrap(),
vec![
"127.0.0.53:53".parse::<SocketAddr>().unwrap(),
"[::1]:53".parse().unwrap()
]
);
assert!(DnsResolver::system("search lan\n").is_err());
}
}