//! DNS SRV record lookup via libc `res_query`. //! //! Discovers bootstrap peers by querying //! `_tesseras._udp.tesseras.net` for SRV records. //! Falls back to an empty list on any DNS or parse error, //! logging a warning so the operator knows discovery failed. //! //! ## Anti-spoofing //! //! Two mitigations against DNS spoofing are applied: //! //! - **DNSSEC (AD flag)**: if the resolver validated the response //! via DNSSEC, the AD bit is set. When AD is absent, a warning //! is logged so the operator knows the response is unauthenticated. //! //! - **Host suffix pinning**: only SRV targets ending in //! `.tesseras.net` are accepted. This limits spoofed responses //! to hosts within the trusted domain. //! //! These checks only apply to the automatic SRV discovery path. //! Explicit `-b` peers bypass DNS entirely. /// Default SRV record name for bootstrap discovery. const SRV_NAME: &str = "_tesseras._udp.tesseras.net"; /// DNS class: Internet. const C_IN: i32 = 1; /// DNS record type: SRV (RFC 2782). const T_SRV: i32 = 33; /// Fixed DNS header size (ID + flags + 4 counts). const HFIXEDSZ: usize = 12; /// Maximum DNS response buffer. Covers UDP responses /// (512 bytes standard, up to 4096 with EDNS0). const MAX_ANSWER: usize = 4096; /// Sanity cap on qdcount/ancount to avoid looping on /// malformed responses (no legitimate response has >64 RRs /// in 4096 bytes). const MAX_RR_COUNT: usize = 64; /// Only SRV targets under this domain suffix are accepted. /// Prevents a spoofed DNS response from directing the daemon /// to attacker-controlled hosts. const TRUSTED_SUFFIX: &str = ".tesseras.net"; /// Bit mask for the AD (Authenticated Data) flag in the DNS /// header flags field (byte 3, bit 5). Set by a validating /// resolver when DNSSEC verification succeeded. const DNS_FLAG_AD: u8 = 0x20; /// A resolved SRV record with target host and port. pub struct SrvRecord { pub host: String, pub port: u16, } /// `h_errno` values from ``. const HOST_NOT_FOUND: i32 = 1; const TRY_AGAIN: i32 = 2; const NO_RECOVERY: i32 = 3; const NO_DATA: i32 = 4; unsafe extern "C" { fn res_query( dname: *const u8, class: i32, rtype: i32, answer: *mut u8, anslen: i32, ) -> i32; fn dn_expand( msg: *const u8, eomorig: *const u8, comp_dn: *const u8, exp_dn: *mut u8, length: i32, ) -> i32; /// Per-thread DNS error code, set by `res_query` on failure. static h_errno: i32; } /// Query DNS for SRV records at `_tesseras._udp.tesseras.net` /// and return the discovered (host, port) pairs. /// Returns an empty Vec on any DNS or parsing failure. pub fn lookup_bootstrap() -> Vec { lookup_srv(SRV_NAME) } /// Perform the SRV query and parse the response. fn lookup_srv(name: &str) -> Vec { let mut buf = vec![0u8; MAX_ANSWER]; // res_query expects a null-terminated C string. let mut cname = name.as_bytes().to_vec(); cname.push(0); // SAFETY: cname is a valid null-terminated byte string, // buf is a properly sized mutable buffer. let len = unsafe { res_query( cname.as_ptr(), C_IN, T_SRV, buf.as_mut_ptr(), buf.len() as i32, ) }; if len < 0 { let reason = match unsafe { h_errno } { HOST_NOT_FOUND => "host not found", TRY_AGAIN => "timeout or temporary failure", NO_RECOVERY => "non-recoverable server error", NO_DATA => "no SRV records for this name", other => { log::warn!( "dns: SRV query for {name} failed (h_errno={other})" ); return Vec::new(); } }; log::warn!("dns: SRV query for {name} failed: {reason}"); return Vec::new(); } let len = len as usize; if len < HFIXEDSZ { log::warn!("dns: SRV response too short ({len} bytes)"); return Vec::new(); } // res_query returns the full (untruncated) response length, // which may exceed the buffer when the answer was truncated. // Cap to the actual buffer size to avoid an out-of-bounds slice. let len = len.min(buf.len()); parse_srv_response(&buf[..len]) } /// Read a big-endian u16 from `data[*pos..]`, advancing `*pos` by 2. /// Returns `None` if there aren't enough bytes remaining. fn read_u16(data: &[u8], pos: &mut usize) -> Option { if *pos + 2 > data.len() { return None; } let val = u16::from_be_bytes([data[*pos], data[*pos + 1]]); *pos += 2; Some(val) } /// Skip over a compressed domain name in the DNS wire format. /// Returns `false` if the name is malformed or extends past the buffer. fn skip_name(data: &[u8], pos: &mut usize) -> bool { while *pos < data.len() { let label_len = data[*pos] as usize; if label_len == 0 { *pos += 1; return true; } // Compression pointer: top 2 bits set, followed by 1 offset byte. if label_len & 0xC0 == 0xC0 { if *pos + 2 > data.len() { return false; } *pos += 2; return true; } if *pos + 1 + label_len > data.len() { return false; } *pos += 1 + label_len; } false } /// Expand a compressed domain name at `msg[*pos..]` using /// libc `dn_expand`. Advances `*pos` past the compressed /// name. Returns `None` on any error. fn expand_name(msg: &[u8], pos: &mut usize) -> Option { if *pos >= msg.len() { return None; } // MAXDNAME is 1025 on OpenBSD; 512 is more than enough // for any valid domain name (max 253 chars + null) and // gives headroom for malformed names without truncation. let mut name_buf = [0u8; 512]; // SAFETY: pos is bounds-checked above, eomorig points to // one past the last byte of msg. dn_expand will not read // past eomorig and writes at most `length` bytes to exp_dn. let n = unsafe { dn_expand( msg.as_ptr(), msg.as_ptr().add(msg.len()), msg.as_ptr().add(*pos), name_buf.as_mut_ptr(), name_buf.len() as i32, ) }; if n < 0 { return None; } *pos += n as usize; // dn_expand null-terminates the output. let end = name_buf.iter().position(|&b| b == 0).unwrap_or(0); String::from_utf8(name_buf[..end].to_vec()).ok() } /// Parse a raw DNS response and extract SRV records. /// Checks the AD flag for DNSSEC validation and rejects /// SRV targets outside the trusted domain suffix. fn parse_srv_response(data: &[u8]) -> Vec { if data.len() < HFIXEDSZ { return Vec::new(); } // Check DNSSEC AD (Authenticated Data) flag. // Byte 3 of the header contains AD at bit 5. if data[3] & DNS_FLAG_AD != 0 { log::info!("dns: response has AD flag (DNSSEC validated)"); } else { log::warn!( "dns: response lacks AD flag (DNSSEC not validated); \ results may be spoofed" ); } let mut pos = 4; // skip ID + flags let qdcount = (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT); let ancount = (read_u16(data, &mut pos).unwrap_or(0) as usize).min(MAX_RR_COUNT); pos += 4; // skip nscount + arcount // Skip question section. for _ in 0..qdcount { if !skip_name(data, &mut pos) { log::debug!("dns: failed to skip question name"); return Vec::new(); } if pos + 4 > data.len() { return Vec::new(); } pos += 4; // qtype + qclass } let mut records = Vec::new(); for _ in 0..ancount { if !skip_name(data, &mut pos) { break; } let rtype = match read_u16(data, &mut pos) { Some(v) => v, None => break, }; // rclass if read_u16(data, &mut pos).is_none() { break; } // ttl (4 bytes) if pos + 4 > data.len() { break; } pos += 4; let rdlength = match read_u16(data, &mut pos) { Some(v) => v as usize, None => break, }; // Guard: rdlength must not extend past the buffer. if pos + rdlength > data.len() { log::debug!("dns: rdlength extends past response buffer"); break; } if rtype != T_SRV as u16 || rdlength < 6 { pos += rdlength; continue; } let rdata_start = pos; // SRV RDATA: priority(2) + weight(2) + port(2) + target if read_u16(data, &mut pos).is_none() { break; } if read_u16(data, &mut pos).is_none() { break; } let srv_port = match read_u16(data, &mut pos) { Some(v) => v, None => break, }; let target = match expand_name(data, &mut pos) { Some(name) => name, None => { pos = rdata_start + rdlength; continue; } }; // Advance to end of RDATA regardless of how much // expand_name consumed (defensive against short reads). pos = rdata_start + rdlength; // SRV target "." means no service available (RFC 2782). if target.is_empty() || target == "." { continue; } // Anti-spoofing: only accept targets under the trusted domain. let lower = target.to_ascii_lowercase(); if !lower.ends_with(TRUSTED_SUFFIX) { log::warn!( "dns: rejecting SRV target '{target}' \ (not under {TRUSTED_SUFFIX})" ); continue; } records.push(SrvRecord { host: target, port: srv_port, }); } records }