diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/bin/tpd.rs | 26 | ||||
| -rw-r--r-- | src/dns.rs | 340 |
2 files changed, 362 insertions, 4 deletions
diff --git a/src/bin/tpd.rs b/src/bin/tpd.rs index 15e7d9b..2b7fdb2 100644 --- a/src/bin/tpd.rs +++ b/src/bin/tpd.rs @@ -10,6 +10,8 @@ mod base58; mod crypto; #[path = "../daemon.rs"] mod daemon; +#[path = "../dns.rs"] +mod dns; #[path = "../ops.rs"] mod ops; #[path = "../paste.rs"] @@ -35,7 +37,7 @@ fn default_dir() -> PathBuf { fn usage() { eprintln!( "usage: tpd [-p port] [-d dir] [-s sock] \ - [-w http_port] [-g] [-b host:port] [-h]" + [-w http_port] [-g] [-n] [-b host:port] [-h]" ); eprintln!(); eprintln!(" -p port UDP port (0 = random)"); @@ -43,6 +45,7 @@ fn usage() { eprintln!(" -s sock Unix socket path"); eprintln!(" -w port HTTP server port"); eprintln!(" -g global NAT (public server)"); + eprintln!(" -n no auto-bootstrap (skip DNS SRV)"); eprintln!(" -b host:port bootstrap peer (repeatable)"); eprintln!(" -h show this help"); } @@ -62,6 +65,7 @@ fn main() { let mut sock: Option<PathBuf> = None; let mut http_port: Option<u16> = None; let mut global = false; + let mut no_auto_bootstrap = false; let mut bootstrap: Vec<String> = Vec::new(); let args: Vec<String> = std::env::args().collect(); @@ -104,6 +108,7 @@ fn main() { ); } "-g" => global = true, + "-n" => no_auto_bootstrap = true, "-b" => { i += 1; if let Some(addr) = args.get(i) { @@ -181,22 +186,35 @@ fn main() { let id = node.id_hex(); eprintln!("tpd {addr} id={:.8}", id); + // If no explicit peers given and auto-bootstrap is enabled, + // discover peers via DNS SRV (_tesseras._udp.tesseras.net). + if bootstrap.is_empty() && !no_auto_bootstrap { + log::info!("bootstrap: resolving SRV records"); + let srv = dns::lookup_bootstrap(); + if srv.is_empty() { + log::warn!("bootstrap: no SRV records found"); + } + for rec in &srv { + bootstrap.push(format!("{}:{}", rec.host, rec.port)); + } + } + for peer in &bootstrap { let parts: Vec<&str> = peer.rsplitn(2, ':').collect(); if parts.len() != 2 { - eprintln!("warning: bad bootstrap: {peer}"); + eprintln!("warning: bad bootstrap address: {peer}"); continue; } let host = parts[1]; let p: u16 = match parts[0].parse() { Ok(p) => p, Err(_) => { - eprintln!("warning: bad port: {peer}"); + eprintln!("warning: bad bootstrap port: {peer}"); continue; } }; if let Err(e) = node.join(host, p) { - eprintln!("warning: bootstrap {peer}: {e}"); + log::warn!("bootstrap: failed to join {peer}: {e}"); } else { log::info!("bootstrap: connected to {peer}"); } diff --git a/src/dns.rs b/src/dns.rs new file mode 100644 index 0000000..8c2af0f --- /dev/null +++ b/src/dns.rs @@ -0,0 +1,340 @@ +//! 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 `<netdb.h>`. +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<SrvRecord> { + lookup_srv(SRV_NAME) +} + +/// Perform the SRV query and parse the response. +fn lookup_srv(name: &str) -> Vec<SrvRecord> { + 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<u16> { + 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<String> { + 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<SrvRecord> { + 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 +} |